def create_command(self, command_resource): """Creates, queues and returns a new command. @param api_key: Api key for the application. @param device_id: Device id of device to send command. @param command_resource: Json dict for command. """ device_id = command_resource.get('deviceId', None) if not device_id: raise server_errors.HTTPError( 400, 'Can only create a command if you provide a deviceId.') if device_id not in self.device_commands: raise server_errors.HTTPError( 400, 'Unknown device with id %s' % device_id) if 'name' not in command_resource: raise server_errors.HTTPError(400, 'Missing command name.') # Print out something useful (command base.Reboot) logging.info('Received command %s', command_resource['name']) # TODO(sosa): Check to see if command is in devices CDD. # Queue command, create it and insert to device->command mapping. command_id = self._generate_command_id() command_resource['id'] = command_id command_resource['state'] = constants.QUEUED_STATE self.device_commands[device_id][command_id] = command_resource return command_resource
def _add_claim_data(self, data): """Adds userEmail to |data| to claim ticket. Raises: server_errors.HTTPError if there is an authorization error. """ access_token = common_util.grab_header_field('Authorization') if not access_token: raise server_errors.HTTPError(401, 'Missing Authorization.') # Authorization should contain "<type> <token>" access_token_list = access_token.split() if len(access_token_list) != 2: raise server_errors.HTTPError(400, 'Malformed Authorization field') [type, code] = access_token_list # TODO(sosa): Consider adding HTTP WWW-Authenticate response header # field if type != 'Bearer': raise server_errors.HTTPError( 403, 'Authorization requires ' 'bearer token.') elif code != RegistrationTickets.TEST_ACCESS_TOKEN: raise server_errors.HTTPError(403, 'Wrong access token.') else: logging.info('Ticket is being claimed.') data['userEmail'] = '*****@*****.**'
def GET(self, *args, **kwargs): """Handle GETs against the command API. GET .../(command_id) returns a command resource GET .../queue?deviceId=... returns the command queue GET .../?deviceId=... returns the command queue Supports both the GET / LIST commands for commands. List lists all devices a user has access to, however, this implementation just returns all devices. Raises: server_errors.HTTPError if the device doesn't exist. """ self._fail_control_handler.ensure_not_in_failure_mode() args = list(args) requested_command_id = args.pop(0) if args else None device_id = kwargs.get('deviceId', None) if args: raise server_errors.HTTPError(400, 'Unsupported API') if not device_id or device_id not in self.device_commands: raise server_errors.HTTPError( 400, 'Can only list commands by valid deviceId.') if requested_command_id is None: requested_command_id = 'queue' if not self._oauth_handler.is_request_authorized(): raise server_errors.HTTPError(401, 'Access denied.') if requested_command_id == 'queue': # Returns listing (ignores optional parameters). listing = {'kind': 'clouddevices#commandsListResponse'} requested_state = kwargs.get('state', None) listing['commands'] = [] for _, command in self.device_commands[device_id].iteritems(): # Check state for match (if None, just append all of them). if (requested_state is None or requested_state == command['state']): listing['commands'].append(command) logging.info('Returning queue of commands: %r', listing) return listing for command_id, resource in self.device_commands[device_id].iteritems( ): if command_id == requested_command_id: return self.device_commands[device_id][command_id] raise server_errors.HTTPError( 400, 'No command with ID=%s found' % requested_command_id)
def _validate_device_resource(self, resource): # Verify required keys exist in the device draft. if not resource: raise server_errors.HTTPError(400, 'Empty device resource.') for key in ['name', 'channel']: if key not in resource: raise server_errors.HTTPError(400, 'Must specify %s' % key) # Add server fields. resource['kind'] = 'clouddevices#device' current_time_ms = str(int(round(time.time() * 1000))) resource['creationTimeMs'] = current_time_ms resource['lastUpdateTimeMs'] = current_time_ms resource['lastSeenTimeMs'] = current_time_ms
def PUT(self, *args, **kwargs): """Replaces the given ticket with the incoming json blob. Format of this call is: PUT .../ticket_number Caller must define a json blob to patch the ticket with. Raises: """ self._fail_control_handler.ensure_not_in_failure_mode() id, api_key, _ = common_util.parse_common_args(args, kwargs) if not id: server_errors.HTTPError(400, 'Missing id for operation') data = common_util.parse_serialized_json() # Handle claiming a ticket with an authorized request. if data and data.get('userEmail') == 'me': self._add_claim_data(data) return self.resource.update_data_val(id, api_key, data_in=data, update=False)
def POST(self, *args, **kwargs): """Either creates a ticket OR claim/finalizes a ticket. This method implements the majority of the registration workflow. More specifically: POST ... creates a new ticket POST .../ticket_number/claim claims a given ticket with a fake email. POST .../ticket_number/finalize finalizes a ticket with a robot account. Raises: server_errors.HTTPError if the ticket should exist but doesn't (claim/finalize) or if we can't parse all the args. """ self._fail_control_handler.ensure_not_in_failure_mode() id, api_key, operation = common_util.parse_common_args( args, kwargs, supported_operations=set(['finalize'])) if operation: ticket = self.resource.get_data_val(id, api_key) if operation == 'finalize': return self._finalize(id, api_key, ticket) else: raise server_errors.HTTPError( 400, 'Unsupported method call %s' % operation) else: data = common_util.parse_serialized_json() if data is None or data.get('userEmail', None) != 'me': raise server_errors.HTTPError( 400, 'Require userEmail=me to create ticket %s' % operation) if [key for key in data.iterkeys() if key != 'userEmail']: raise server_errors.HTTPError( 400, 'Extra data for ticket creation: %r.' % data) if id: raise server_errors.HTTPError(400, 'Should not specify ticket ID.') self._add_claim_data(data) # We have an insert operation so make sure we have all required # fields. data.update(self._default_registration_ticket()) logging.info('Ticket is being created.') return self.resource.update_data_val(id, api_key, data_in=data)
def parse_common_args(args_tuple, kwargs, supported_operations=set()): """Common method to parse args to a CherryPy RPC for this server. |args_tuple| should contain all the sections of the URL after CherryPy removes the pieces that dispatched the URL to this handler. For instance, a GET method receiving '...'/<id>/<method_name> should call: parse_common_args(args_tuple=[<id>, <method_name>]). Some operations take no arguments. Other operations take a single argument. Still other operations take one of supported_operations as a second argument (in the args_tuple). @param args_tuple: Tuple of positional args. @param kwargs: Dictionary of named args passed in. @param supported_operations: Set of operations to support if any. Returns: A 3-tuple containing the id parsed from the args_tuple, api_key, and finally an optional operation if supported_operations is provided and args_tuple contains one of the supported ops. Raises: server_error.HTTPError if combination or args/kwargs doesn't make sense. """ args = list(args_tuple) api_key = kwargs.get('key') id = args.pop(0) if args else None operation = args.pop(0) if args else None if operation: if not supported_operations: raise server_errors.HTTPError( 400, 'Received operation when operation was not ' 'expected: %s!' % operation) elif not operation in supported_operations: raise server_errors.HTTPError( 400, 'Unsupported operation: %s' % operation) # All expected args should be popped off already. if args: raise server_errors.HTTPError(400, 'Could not parse all args: %s' % args) return id, api_key, operation
def POST(self, *args, **kwargs): """Creates a new command using the incoming json data.""" # TODO(wiley) We could check authorization here, which should be # a client/owner of the device. self._fail_control_handler.ensure_not_in_failure_mode() data = common_util.parse_serialized_json() if not data: raise server_errors.HTTPError(400, 'Require JSON body') return self.create_command(data)
def ensure_not_in_failure_mode(self): """Ensures we're not in failure mode. If instructed to fail, this method raises an HTTPError exception with code 500 (Internal Server Error). Otherwise does nothing. """ if not self._in_failure_mode: return raise server_errors.HTTPError(500, 'Instructed to fail this request')
def POST(self, *args, **kwargs): """Handle a post to get a refresh/access token. We expect the device to provide (a subset of) the following parameters. code client_id client_secret redirect_uri scope grant_type refresh_token in the request body in query-string format (see the OAuth docs for details). Since we're a bare-minimum implementation we're going to ignore most of these. """ self._fail_control_handler.ensure_not_in_failure_mode() path = list(args) if path == ['token']: body_length = int(cherrypy.request.headers.get( 'Content-Length', 0)) body = cherrypy.request.rfile.read(body_length) params = cherrypy.lib.httputil.parse_query_string(body) refresh_token = params.get('refresh_token') if refresh_token and refresh_token != self._device_refresh_token: logging.info( 'Wrong refresh token - expected %s but ' 'device sent %s', self._device_refresh_token, refresh_token) cherrypy.response.status = 400 response = {'error': 'invalid_grant'} return response response = { 'access_token': self._device_access_token, 'refresh_token': self._device_refresh_token, 'expires_in': TOKEN_EXPIRATION_SECONDS, } return response elif path == ['invalidate_all_access_tokens']: # By concatenating '_X' to the end of existing access # token, this will effectively invalidate the access token # previously granted to a device and cause us to return # the concatenated one for future requests. self._device_access_token += '_X' return dict() elif path == ['invalidate_all_refresh_tokens']: # Same here, only for the refresh token. self._device_refresh_token += '_X' return dict() else: raise server_errors.HTTPError(400, 'Unsupported oauth path %s' % path)
def POST(self, *args, **kwargs): """Handle POST messages.""" path = list(args) if path == ['start_failing_requests']: self._in_failure_mode = True logging.info('Requested to start failing all requests.') return dict() elif path == ['stop_failing_requests']: self._in_failure_mode = False logging.info('Requested to stop failing all requests.') return dict() else: raise server_errors.HTTPError( 400, 'Unsupported fail_control path %s' % path)
def _finalize(self, id, api_key, ticket): """Finalizes the ticket causing the server to add robot account info.""" if 'userEmail' not in ticket: raise server_errors.HTTPError(400, 'Unclaimed ticket') robot_account_email = '*****@*****.**' robot_auth = uuid.uuid4().hex new_data = { 'robotAccountEmail': robot_account_email, 'robotAccountAuthorizationCode': robot_auth } updated_data_val = self.resource.update_data_val(id, api_key, new_data) updated_data_val['deviceDraft'] = self.devices_instance.create_device( api_key, updated_data_val.get('deviceDraft')) return updated_data_val
def del_data_val(self, id, api_key): """Deletes the data value for the given id, api_key pair. @param id: ID for data val. @param api_key: optional api_key for the data_val. Raises: server_errors.HTTPError if the data_val doesn't exist. """ key = (id, api_key) if key not in self._data: # Put the tuple we want inside another tuple, so that Python doesn't # unroll |key| and complain that we haven't asked to printf two # values. raise server_errors.HTTPError(400, 'Invalid data key: %r' % (key,)) del self._data[key]
def PATCH(self, *args, **kwargs): """Updates the given resource with the incoming json blob. Format of this call is: PATCH .../resource_id Caller must define a json blob to patch the resource with. Raises: server_errors.HTTPError if the resource doesn't exist. """ id, api_key, _ = common_util.parse_common_args(args, kwargs) if not id: server_errors.HTTPError(400, 'Missing id for operation') data = common_util.parse_serialized_json() return self.resource.update_data_val(id, api_key, data_in=data)
def PATCH(self, *args, **kwargs): """Updates the given ticket with the incoming json blob. Format of this call is: PATCH .../ticket_number Caller must define a json blob to patch the ticket with. Raises: server_errors.HTTPError if the ticket doesn't exist. """ self._fail_control_handler.ensure_not_in_failure_mode() id, api_key, _ = common_util.parse_common_args(args, kwargs) if not id: server_errors.HTTPError(400, 'Missing id for operation') data = common_util.parse_serialized_json() return self.resource.update_data_val(id, api_key, data_in=data)
def update_data_val(self, id, api_key, data_in=None, update=True): """Helper method for all mutations to data vals. If the id isn't given, creates a new template default with a new id. Otherwise updates/replaces the given dict with the data based on update. @param id: id (if None, creates a new data val). @param api_key: optional api_key. @param data_in: data dictionary to either update or replace current. @param update: fully replace data_val given by id, api_key with data_in. Raises: server_errors.HTTPError if the id is non-None and not in self._data. """ data_val = None if not id: # This is an insertion. if not data_in: raise ValueError('Either id OR data_in must be specified.') # Create a new id and insert the data blob into our dictionary. id = uuid.uuid4().hex[0:6] data_in['id'] = id self._data[(id, api_key)] = data_in return data_in data_val = self.get_data_val(id, api_key) if not data_in: logging.warning('Received empty data update. Doing nothing.') return data_val # Update or replace the existing data val. if update: data_val.update(data_in) else: if data_val.get('id') != data_in.get('id'): raise server_errors.HTTPError(400, "Ticket id doesn't match") data_val = data_in self._data[(id, api_key)] = data_in return data_val
def POST(self, *args, **kwargs): """Handle POSTs for a device. Supported APIs include: POST /devices/<device-id>/patchState """ self._fail_control_handler.ensure_not_in_failure_mode() args = list(args) device_id = args.pop(0) if args else None operation = args.pop(0) if args else None if device_id is None or operation != 'patchState': raise server_errors.HTTPError(400, 'Unsupported operation.') data = common_util.parse_serialized_json() access_token = common_util.get_access_token() api_key = self._oauth.get_api_key_from_access_token(access_token) self._handle_state_patch(device_id, api_key, data) return {'state': self.resource.get_data_val(device_id, api_key)['state']}
def PUT(self, *args, **kwargs): """Update an existing device using the incoming json data. On startup, devices make a request like: PUT http://<server-host>/devices/<device-id> {'channel': {'supportedType': 'xmpp'}, 'commandDefs': {}, 'description': 'test_description ', 'displayName': 'test_display_name ', 'id': '4471f7', 'location': 'test_location ', 'name': 'test_device_name', 'state': {'base': {'firmwareVersion': '6771.0.2015_02_09_1429', 'isProximityTokenRequired': False, 'localDiscoveryEnabled': False, 'manufacturer': '', 'model': '', 'serialNumber': '', 'supportUrl': '', 'updateUrl': ''}}} This PUT has no API key, but comes with an OAUTH access token. """ self._fail_control_handler.ensure_not_in_failure_mode() device_id, _, _ = common_util.parse_common_args(args, kwargs) access_token = common_util.get_access_token() if not access_token: raise server_errors.HTTPError(401, 'Access denied.') api_key = self._oauth.get_api_key_from_access_token(access_token) data = common_util.parse_serialized_json() self._validate_device_resource(data) logging.info('Updating device with id=%s and device_config=%r', device_id, data) new_device = self.resource.update_data_val(device_id, api_key, data_in=data) return data