Example #1
0
async def read(request: Request) -> HTTPResponse:
    """Read data from devices which match the set of provided tags.

    Reading data is returned for only those devices which match all of the
    specified tags.

    Args:
        request: The Sanic request object.

    Query Parameters:
        ns: The default namespace to use for specified tags without explicit namespaces.
            Only one default namespace may be specified. (default: ``default``)
        tags: The tags to filter devices by. Multiple tag groups may be specified by
            providing multiple ``tags`` query parameters, e.g. ``?tags=foo&tags=bar``.
            Each tag group may consist of one or more comma-separated tags. Each tag
            group only selects devices which match all of the tags in the group. If
            multiple tag groups are specified, the result is the union of the matches
            from each individual tag group.
        plugin: The ID of the plugin to get device readings from. If not specified,
            all plugins are considered valid for reading.

    Returns:
        A JSON-formatted HTTP response with the possible statuses:
          * 200: OK
          * 400: Invalid parameter(s)
          * 500: Catchall processing error
    """
    namespace = 'default'
    param_ns = request.args.getlist('ns')
    if param_ns:
        if len(param_ns) > 1:
            raise errors.InvalidUsage(
                'invalid parameter: only one default namespace may be specified',
            )
        namespace = param_ns[0]

    tag_groups = []
    param_tags = request.args.getlist('tags')
    if param_tags:
        for group in param_tags:
            tag_groups.append(group.split(','))

    plugin_id = request.args.get('plugin', None)
    if plugin_id and plugin_id not in plugin.manager.plugins:
        raise errors.InvalidUsage(
            'invalid parameter: specified plugin ID does not correspond with known plugin',
        )

    try:
        return utils.http_json_response(
            await cmd.read(
                ns=namespace,
                tag_groups=tag_groups,
                plugin_id=plugin_id,
            ), )
    except Exception:
        logger.exception('failed to read device(s)',
                         namespace=namespace,
                         tag_groups=tag_groups)
        raise
Example #2
0
async def read_cache(request: Request) -> StreamingHTTPResponse:
    """Stream cached reading data from the registered plugins.

    Plugins may optionally cache readings for a given time window. This endpoint
    exposes the data in that cache. If a readings cache is not configured for a
    plugin, a snapshot of its current reading state is streamed back in the response.

    Args:
        request: The Sanic request object.

    Query Parameters:
        start: An RFC3339 formatted timestamp which specifies a starting bound on the
            cache data to return. If left unspecified, there will be no starting bound.
        end: An RFC3339 formatted timestamp which specifies an ending bound on the
            cache data to return. If left unspecified, there will be no ending bound.

    Returns:
        A JSON-formatted HTTP response with the possible statuses:
          * 200: OK
          * 400: Invalid query parameter(s)
          * 500: Catchall processing error
    """
    start = ''
    param_start = request.args.getlist('start')
    if param_start:
        if len(param_start) > 1:
            raise errors.InvalidUsage(
                'invalid parameter: only one cache start may be specified',
            )
        start = param_start[0]

    end = ''
    param_end = request.args.getlist('end')
    if param_end:
        if len(param_end) > 1:
            raise errors.InvalidUsage(
                'invalid parameter: only one cache end may be specified',
            )
        end = param_end[0]

    # Define the function that will be used to stream the responses back.
    async def response_streamer(response):
        # Due to how streamed responses are handled, an exception here won't
        # end up surfacing to the user through Synse's custom error handler
        # and instead appear to create an opaque error relating to an invalid
        # character in the chunk header. Instead of surfacing that error, we
        # just log it and move on.
        try:
            async for reading in cmd.read_cache(start, end):
                try:
                    await response.write(ujson.dumps(reading, reject_bytes=False) + '\n')
                except Exception:
                    logger.exception('error streaming cached reading response', reading=reading)
        except Exception:
            logger.exception('failure when streaming cached readings')

    return stream(response_streamer, content_type='application/json; charset=utf-8')
Example #3
0
async def device(request: Request, device_id: str) -> HTTPResponse:
    """Read or write to the specified device.

    This endpoint provides read/write access to all devices via their deterministic
    GUID. The underlying implementations for read and write are the same as the
    ``/read/{device}`` and ``/write/wait/{device}`` endpoints, respectively.

    Args:
        request: The Sanic request object.
        device_id: The ID of the device that is being read from/written to.

    Returns:
        A JSON-formatted HTTP response with the possible statuses:
          * 200: OK
          * 400: Invalid JSON provided / Invalid parameter(s)
          * 404: Device not found
          * 405: Device does not support reading/writing
          * 500: Catchall processing error
    """
    if request.method == 'GET':
        try:
            return utils.http_json_response(
                await cmd.read_device(device_id),
            )
        except Exception:
            logger.exception('failed to read device', id=device_id)
            raise

    else:
        try:
            data = request.json
        except Exception as e:
            raise errors.InvalidUsage(
                'invalid json: unable to parse POSTed body as JSON'
            ) from e

        # Validate that the incoming payload has an 'action' field defined. This
        # field is required. All other fields are optional.
        if isinstance(data, dict):
            data = [data]
        for item in data:
            if 'action' not in item:
                raise errors.InvalidUsage(
                    'invalid json: key "action" is required in payload, but not found'
                )

        try:
            return utils.http_json_response(
                await cmd.write_sync(
                    device_id=device_id,
                    payload=data,
                ),
            )
        except Exception:
            logger.exception('failed to write synchronously', id=device_id, payload=data)
            raise
Example #4
0
async def sync_write(request: Request, device_id: str) -> HTTPResponse:
    """Write data to a device synchronously, waiting for the write to complete.

    The length of time it takes for a write to complete depends on both the device
    and its plugin. It is up to the caller to define a sane request timeout so the
    request does not prematurely terminate.

    Args:
        request: The Sanic request object.
        device_id: The ID of the device that is being written to.

    Returns:
        A JSON-formatted HTTP response with the possible statuses:
          * 200: OK
          * 400: Invalid JSON provided
          * 404: Device not found
          * 405: Device does not support writing
          * 500: Catchall processing error
    """
    logger.debug('parsing request body', payload=request.body)

    try:
        data = request.json
    except Exception as e:
        raise errors.InvalidUsage(
            'invalid json: unable to parse POSTed body as JSON') from e

    # Validate that the incoming payload has an 'action' field defined. This
    # field is required. All other fields are optional.
    if isinstance(data, dict):
        data = [data]
    for item in data:
        if 'action' not in item:
            raise errors.InvalidUsage(
                'invalid json: key "action" is required in payload, but not found'
            )

    try:
        return utils.http_json_response(
            await cmd.write_sync(
                device_id=device_id,
                payload=data,
            ), )
    except Exception:
        logger.exception('failed to write synchronously',
                         id=device_id,
                         payload=data)
        raise
Example #5
0
async def async_write(request: Request, device_id: str) -> HTTPResponse:
    """Write data to a device in an asynchronous manner.

    The write will generate a transaction ID for each write payload to the
    specified device. The transaction can be checked later via the ``transaction``
    endpoint.

    Args:
        request: The Sanic request object.
        device_id: The ID of the device that is being written to.

    Returns:
        A JSON-formatted HTTP response with the possible statuses:
          * 200: OK
          * 400: Invalid JSON provided
          * 404: Device not found
          * 405: Device does not support writing
          * 500: Catchall processing error
    """
    logger.debug('parsing request body', payload=request.body)

    try:
        data = request.json
    except Exception as e:
        raise errors.InvalidUsage(
            'invalid json: unable to parse POSTed body as JSON') from e

    # Validate that the incoming payload has an 'action' field defined. This
    # field is required. All other fields are optional.
    if isinstance(data, dict):
        data = [data]
    for item in data:
        if 'action' not in item:
            raise errors.InvalidUsage(
                'invalid json: key "action" is required in payload, but not found'
            )

    try:
        return utils.http_json_response(
            await cmd.write_async(
                device_id=device_id,
                payload=data,
            ), )
    except Exception:
        logger.exception('failed to write asynchronously',
                         id=device_id,
                         payload=data)
        raise
Example #6
0
    def test_make_response(self):

        ex = errors.InvalidUsage('context error message')
        resp = ex.make_response()

        assert isinstance(resp, dict)
        assert resp == {
            'http_code': 400,
            'description': 'invalid user input',
            'timestamp': '2019-04-22T13:30:00Z',
            'context': 'context error message',
        }
Example #7
0
def test_error_synse_error():
    resp = websocket.error(
        msg_id=1,
        ex=errors.InvalidUsage('invalid'),
    )

    assert resp == {
        'id': 1,
        'event': 'response/error',
        'data': {
            'http_code': 400,
            'description': 'invalid user input',
            'timestamp': '2019-04-22T13:30:00Z',
            'context': 'invalid',
        }
    }
Example #8
0
async def scan(request: Request) -> HTTPResponse:
    """List the devices that Synse knows about.

    This endpoint provides an aggregated view of all devices exposed to
    Synse Server by each of the registered plugins. By default, the scan
    results are sorted by a combination key of 'plugin,sortIndex,id'.

    Args:
        request: The Sanic request object.

    Query Parameters:
        ns: The default namespace to use for specified tags without explicit namespaces.
            Only one default namespace may be specified. (default: ``default``)
        tags: The tags to filter devices by. Multiple tag groups may be specified by
            providing multiple ``tags`` query parameters, e.g. ``?tags=foo&tags=bar``.
            Each tag group may consist of one or more comma-separated tags. Each tag
            group only selects devices which match all of the tags in the group. If
            multiple tag groups are specified, the result is the union of the matches
            from each individual tag group.
        force: Force a re-scan (rebuild the internal cache). This will take longer than
            a scan which uses the cache. (default: false)
        sort: Specify the fields to sort by. Multiple fields may be specified as a
            comma-separated string, e.g. "plugin,id". The "tags" field can not be used
            for sorting. (default: "plugin,sortIndex,id", where ``sortIndex`` is an
            internal sort preference which a plugin can optionally specify.)

    Returns:
        A JSON-formatted HTTP response with the possible statuses:
          * 200: OK
          * 400: Invalid parameter(s)
          * 500: Catchall processing error
    """
    namespace = 'default'
    param_ns = request.args.getlist('ns')
    if param_ns:
        if len(param_ns) > 1:
            raise errors.InvalidUsage(
                'invalid parameter: only one namespace may be specified', )
        namespace = param_ns[0]

    tag_groups = []
    param_tags = request.args.getlist('tags')
    if param_tags:
        for group in param_tags:
            tag_groups.append(group.split(','))

    force = request.args.get('force', 'false').lower() == 'true'

    sort_keys = 'plugin,sortIndex,id'
    param_sort = request.args.getlist('sort')
    if param_sort:
        if len(param_sort) > 1:
            raise errors.InvalidUsage(
                'invalid parameter: only one sort key may be specified', )
        sort_keys = param_sort[0]

    try:
        return utils.http_json_response(
            await cmd.scan(
                ns=namespace,
                tag_groups=tag_groups,
                force=force,
                sort=sort_keys,
            ), )
    except Exception:
        logger.exception('failed to get devices (scan)')
        raise
Example #9
0
async def scan(
    ns: str,
    tag_groups: List[List[str]],
    sort: str,
    force: bool = False,
) -> List[Dict[str, Any]]:
    """Generate the scan response data.

    Args:
        ns: The default namespace to use for tags which do not specify one.
            If all tags specify a namespace, or no tags are defined, this
            is ignored.
        tag_groups: The tags groups used to filter devices. If no tag groups
            are given (and thus no tags), no filtering is done.
        force: Option to force rebuild the internal device cache. (default: False)
        sort: The fields to sort by.

    Returns:
        A list of dictionary representations of device summary response(s).
    """
    logger.info(
        'issuing command',
        command='SCAN',
        ns=ns,
        tag_groups=tag_groups,
        sort=sort,
        force=force,
    )

    # If the force flag is set, rebuild the internal device cache. This
    # will ensure everything is up to date, but will ultimately make the
    # request take longer to fulfill.
    if force:
        logger.debug('forced scan: rebuilding device cache', command='SCAN')
        try:
            await cache.update_device_cache()
        except Exception as e:
            raise errors.ServerError('failed to rebuild device cache') from e

    # If no tags are specified, get devices with no tag filter.
    if len(tag_groups) == 0:
        logger.debug('getting devices with no tag filter', command='SCAN')
        try:
            devices = await cache.get_devices()
        except Exception as e:
            logger.exception(e)
            raise errors.ServerError(
                'failed to get all devices from cache') from e

    else:
        # Otherwise, there is at least one tag group. We need to get the devices for
        # each tag group and collect the results of each group.
        results = {}
        logger.debug('parsing tag groups', command='SCAN')
        for group in tag_groups:
            # Apply the default namespace to the tags in the group which do not
            # have any namespace defined.
            for i, tag in enumerate(group):
                if '/' not in tag:
                    group[i] = f'{ns}/{tag}'

            try:
                device_group = await cache.get_devices(*group)
            except Exception as e:
                logger.exception(e)
                raise errors.ServerError(
                    'failed to get devices from cache') from e

            for device in device_group:
                results[device.id] = device

        devices = list(results.values())

    # Sort the devices based on the sort string. There may be multiple
    # components in the sort string separated by commas. The order in which
    # they are listed is equivalent to the order of their sort priority.
    sort_keys = sort.split(',')

    try:
        logger.debug('sorting devices', command='SCAN')
        sorted_devices = sorted(
            devices,
            key=lambda dev: tuple(map(lambda key: getattr(dev, key), sort_keys)
                                  ))
    except AttributeError as e:
        raise errors.InvalidUsage('invalid sort key(s) provided') from e

    response = []
    for device in sorted_devices:
        response.append({
            'id': device.id,
            'alias': device.alias,
            'info': device.info,
            'type': device.type,
            'plugin': device.plugin,
            'tags': [utils.tag_string(tag) for tag in device.tags],
            'metadata': dict(device.metadata),
        })
    logger.debug('got devices', count=len(response), command='SCAN')
    return response