aws / chalice

Python Serverless Microframework for AWS
Apache License 2.0
10.66k stars 1.01k forks source link

WebSockets support only available within WebSockets methods (ValueError: WebsocketAPI.configure must be called before using the WebsocketAPI) #1991

Open convexset opened 2 years ago

convexset commented 2 years ago

It seems that WebSockets support only available within WebSockets methods. From other application code, one gets:

ValueError: WebsocketAPI.configure must be called before using the WebsocketAPI

This may seem alright, but application code may need to communicate with clients connected over WebSockets.

I don't think it's too much to ask to have this enabled by default.

To work around this, what I've done is to have a script put my Chalice exports into chalicelib/chalice_exports.py which looks like:

exports = {
    "dev": {
        "rest_api": {
            "name": "rest_api",
            "resource_type": "rest_api",
            "rest_api_id": "abc123rd",
            "rest_api_url": "https://abc123rd.execute-api.ap-southeast-1.amazonaws.com/api-dev/"
        },
        "websocket_api": {
            "name": "websocket_api",
            "resource_type": "websocket_api",
            "websocket_api_url": "wss://abc123wd.execute-api.ap-southeast-1.amazonaws.com/api-dev/",
            "websocket_api_id": "abc123wd"
        }
    },
    "prod": {
        "rest_api": {
            "name": "rest_api",
            "resource_type": "rest_api",
            "rest_api_id": "abc123pr",
            "rest_api_url": "https://abc123pr.execute-api.ap-southeast-1.amazonaws.com/api/"
        },
        "websocket_api": {
            "name": "websocket_api",
            "resource_type": "websocket_api",
            "websocket_api_url": "wss://abc123pw.execute-api.ap-southeast-1.amazonaws.com/api/",
            "websocket_api_id": "abc123pw"
        }
    }
}

(I actually use Chalice with Amplify, so this had to be done to provide the same information to the front end.)

Then I have somewhere in my application code:

STAGE = os.environ.get('STAGE', 'dev')

# ...

websocket_api = None
def inject_websocket_api(_websocket_api):
    global websocket_api
    websocket_api = _websocket_api
    if websocket_api._endpoint is None:
        log.info('-' * 40)
        log.info(f'WebSockets Endpoint not set. Configuration needed.')
        current_chalice_exports = chalice_exports.exports.get(STAGE)
        if current_chalice_exports is not None:
            websocket_api_domain = current_chalice_exports['websocket_api']['websocket_api_url'].split('/')[2]
            websocket_api_stage = current_chalice_exports['websocket_api']['websocket_api_url'].split('/')[3]
            websocket_api.configure(websocket_api_domain, websocket_api_stage)
            log.info(f' - Configuring WebSockets: domain={websocket_api_domain}, stage={websocket_api_stage}')
        else: 
            log.info(f' - Information to configure WebSockets not available')
        log.info('-' * 40)
    else:
        log.info('-' * 40)
        log.info(f'WebSockets Configured: websocket_api._endpoint={websocket_api._endpoint}')
        log.info('-' * 40)

This is called from app.py as such:

chalicelib.websockets.inject_websocket_api(app.websocket_api)

(Sorry, a little lazy to edit this.)

convexset commented 2 years ago

I see how this might be troublesome since this seems to be configured within the WebsocketEventSourceHandler at...

https://github.com/aws/chalice/blob/master/chalice/app.py#L1768

... which is super easy and convenient. However, all other request contexts do not include this.

I would say that an automatically generated list of the last deployed resources on app would be a viable solution. That is, the content of .chalice/deployed/MYSTAGE.json. This means that a deploy would be needed, followed by another if there is a non-zero "diff".

This architectural change is troublesome, but does open up additional possibilities. How about CFN/CDK with exports available to the app?

convexset commented 1 year ago

Additionally, as it is, this fails with event sources. My workaround is "doing the regular thing" of setting up my own send method based on the aforementioned deployed data:

import boto3
from chalice import WebsocketDisconnectedError

# after each deployment, data from .chalice/deployed/ is written to...
from . import chalice_exports 

STAGE = os.environ.get('STAGE', 'dev')

current_chalice_exports = chalice_exports.exports.get(STAGE)

apigw_management_client = None
if current_chalice_exports is not None:
    websocket_api_domain = current_chalice_exports['websocket_api']['websocket_api_url'].split('/')[2]
    websocket_api_stage = current_chalice_exports['websocket_api']['websocket_api_url'].split('/')[3]
    log.info(f'Configuring WebSockets: domain={websocket_api_domain}, stage={websocket_api_stage}')
    apigw_management_client = boto3.client(
        'apigatewaymanagementapi',
        endpoint_url=f'https://{websocket_api_domain}/{websocket_api_stage}'
    )
else:
    log.error(f'Information to configure WebSockets not available')

def _send(connection_id, serialized_data):
    # https://github.com/aws/chalice/blob/master/chalice/app.py#L687
    if apigw_management_client is None:
        raise Exception('websocket_api not yet deployed')
    try:
        result = apigw_management_client.post_to_connection(
            ConnectionId=connection_id,
            Data=serialized_data,
        )
    except apigw_management_client.exceptions.GoneException:
        raise WebsocketDisconnectedError(connection_id)
    return result