def patch(self, **kwargs): """ summary : Update {class_name} description: Update {class_name} attributes responses: 200 : description : Request fulfilled, document follows 202 : description : Accepted 204 : description : No Content 403: description : Forbidden 404 : description : Not Found 409 : description : Conflict --- https://jsonapi.org/format/#crud-updating-responses Update the object with the specified id """ id = kwargs.get(self._s_object_id, None) payload = request.get_jsonapi_payload() if not isinstance(payload, dict): raise ValidationError("Invalid Object Type") data = payload.get("data") if id is None and isinstance(data, list): # Bulk patch request for item in data: if not isinstance(item, dict): raise ValidationError("Invalid Data Object") instance = self._patch_instance(item) response = make_response(jsonify({}), HTTPStatus.ACCEPTED) elif not data or not isinstance(data, dict): raise ValidationError("Invalid Data Object") elif id is None: raise ValidationError("Invalid ID") else: instance = self._patch_instance(data, id) # object id is the endpoint parameter, for example "UserId" for a User SAFRSObject obj_args = {instance._s_object_id: instance.jsonapi_id} # Retrieve the jsonapi encoded object and return it to the client obj_data = self.get(**obj_args) response = make_response(obj_data, HTTPStatus.OK) # Set the Location header to the newly created object response.headers["Location"] = url_for(self.endpoint, **obj_args) return response
def post(self, **kwargs): """ summary : call responses : 403: description : 201: description: Created 202: description : Accepted 403 : description : Forbidden 404: description : Not Found 409: description : Conflict --- HTTP POST: apply actions, return 200 regardless. The actual jsonapi_rpc method may return other codes """ id = kwargs.get(self._s_object_id, None) if id is not None: instance = self.SAFRSObject.get_instance(id) if not instance: # If no instance was found this means the user supplied # an invalid ID raise ValidationError("Invalid ID") else: # No ID was supplied, apply method to the class itself instance = self.SAFRSObject method = getattr(instance, self.method_name, None) if not method: # Only call methods for Campaign and not for superclasses (e.g. safrs.DB.Model) raise ValidationError('Invalid method "{}"'.format( self.method_name)) if not is_public(method): raise ValidationError("Method is not public") args = dict(request.args) if getattr(method, "valid_jsonapi", False): payload = request.get_jsonapi_payload() if payload: args = payload.get("meta", {}).get("args", {}) else: args = request.get_json() return self._create_rpc_response(method, args)
def patch(self, **kwargs): """ responses: 200 : description : Accepted 201 : description: Created 204 : description : No Content 403: description : Forbidden 404 : description : Not Found 409 : description : Conflict ---- Update or create a relationship child item to be used to create or update one-to-many mappings but also works for many-to-many etc. # Updating To-One Relationships http://jsonapi.org/format/#crud-updating-to-one-relationships: A server MUST respond to PATCH requests to a URL from a to-one relationship link as described below The PATCH request MUST include a top-level member named data containing one of: a resource identifier object corresponding to the new related resource. null, to remove the relationship. """ parent, relation = self.parse_args(**kwargs) json_reponse = request.get_jsonapi_payload() if not isinstance(json_reponse, dict): raise ValidationError("Invalid Object Type") data = json_reponse.get("data") relation = getattr(parent, self.rel_name) obj_args = {self.parent_object_id: parent.jsonapi_id} if isinstance(data, dict): # => Update TOONE Relationship # TODO!!! if self.SAFRSObject.relationship.direction != MANYTOONE: raise GenericError( "To PATCH a TOMANY relationship you should provide a list") child = self.child_class.get_instance(data.get("id", None)) setattr(parent, self.rel_name, child) obj_args[self.child_object_id] = child.jsonapi_id elif isinstance(data, list): """ http://jsonapi.org/format/#crud-updating-to-many-relationships If a client makes a PATCH request to a URL from a to-many relationship link, the server MUST either completely replace every member of the relationship, return an appropriate error response if some resourcescan not be found or accessed, or return a 403 Forbidden response if complete replacement is not allowed by the server. """ if self.SAFRSObject.relationship.direction == MANYTOONE: raise GenericError("To PATCH a MANYTOONE relationship you \ should provide a dictionary instead of a list") # first remove all items, then append the new items # if the relationship has been configured with lazy="dynamic" # then it is a subclass of AppenderBaseQuery and # we should empty the relationship by setting it to [] # otherwise it is an instance of InstrumentedList and we have to empty it # ( we could loop all items but this is slower for large collections ) tmp_rel = [] for child in data: if not isinstance(child, dict): raise ValidationError("Invalid data object") child_instance = self.child_class.get_instance(child["id"]) tmp_rel.append(child_instance) if isinstance(relation, sqlalchemy.orm.collections.InstrumentedList): relation[:] = tmp_rel else: setattr(parent, self.rel_name, tmp_rel) elif data is None: # { data : null } //=> clear the relationship if self.SAFRSObject.relationship.direction == MANYTOONE: child = getattr(parent, self.SAFRSObject.relationship.key) if child: pass setattr(parent, self.rel_name, None) else: # # should we allow this?? # maybe we just want to raise an error here ...??? setattr(parent, self.rel_name, []) else: raise ValidationError("Invalid Data Object Type") if data is None: # item removed from relationship => 202 accepted # TODO: add response to swagger # add meta? response = {}, 200 else: obj_data = self.get(**obj_args) # Retrieve the object json and return it to the client response = make_response(obj_data, 201) # Set the Location header to the newly created object # todo: set location header # response.headers['Location'] = url_for(self.endpoint, **obj_args) return response
def post(self, **kwargs): """ responses : 403: description : This implementation does not accept client-generated IDs 201: description: Created 202: description : Accepted 404: description : Not Found 409: description : Conflict --- http://jsonapi.org/format/#crud-creating Creating Resources A resource can be created by sending a POST request to a URL that represents a collection of resources. The request MUST include a single resource object as primary data. The resource object MUST contain at least a type member. If a relationship is provided in the relationships member of the resource object, its value MUST be a relationship object with a data member. The value of this key represents the linkage the new resource is to have. Response: 403: This implementation does not accept client-generated IDs 201: Created 202: Accepted 404: Not Found 409: Conflict Location Header identifying the location of the newly created resource Body : created object TODO: 409 Conflict A server MUST return 409 Conflict when processing a POST request to create a resource with a client-generated ID that already exists. A server MUST return 409 Conflict when processing a POST request in which the resource object’s type is not among the type(s) that constitute the collection represented by the endpoint. A server SHOULD include error details and provide enough information to recognize the source of the conflict. """ payload = request.get_jsonapi_payload() method_name = payload.get("meta", {}).get("method", None) id = kwargs.get(self.object_id, None) if id is not None: # POSTing to an instance isn't jsonapi-compliant (https://jsonapi.org/format/#crud-creating-client-ids) # "A server MUST return 403 Forbidden in response to an # unsupported request to create a resource with a client-generated ID" response = {"meta": {"error": "Unsupported JSONAPI Request"}}, 403 return response else: # Create a new instance of the SAFRSObject data = payload.get("data") if data is None: raise ValidationError("Request contains no data") if not isinstance(data, dict): raise ValidationError("data is not a dict object") obj_type = data.get("type", None) if not obj_type: # or type.. raise ValidationError("Invalid type member") attributes = data.get("attributes", {}) # Remove 'id' (or other primary keys) from the attributes, unless it is allowed by the # SAFRSObject allow_client_generated_ids attribute for col_name in [c.name for c in self.SAFRSObject.id_type.columns]: attributes.pop(col_name, None) # remove attributes that have relationship names attributes = { attr_name: attributes[attr_name] for attr_name in attributes if attr_name not in self.SAFRSObject._s_relationship_names } if getattr(self.SAFRSObject, "allow_client_generated_ids", False) is True: # todo, this isn't required per the jsonapi spec, doesn't work well and isn't documented, maybe later id = data.get("id") self.SAFRSObject.id_type.get_pks(id) # Create the object instance with the specified id and json data # If the instance (id) already exists, it will be updated with the data # pylint: disable=not-callable instance = self.SAFRSObject(**attributes) if not instance.db_commit: # # The item has not yet been added/commited by the SAFRSBase, # in that case we have to do it ourselves # safrs.DB.session.add(instance) try: safrs.DB.session.commit() except sqlalchemy.exc.SQLAlchemyError as exc: # Exception may arise when a db constrained has been violated # (e.g. duplicate key) safrs.log.warning(str(exc)) raise GenericError(str(exc)) # object_id is the endpoint parameter, for example "UserId" for a User SAFRSObject obj_args = {instance.object_id: instance.jsonapi_id} # Retrieve the object json and return it to the client obj_data = self.get(**obj_args) response = make_response(obj_data, 201) # Set the Location header to the newly created object response.headers["Location"] = url_for(self.endpoint, **obj_args) return response
def post(self, **kwargs): """ responses : 403: description : This implementation does not accept client-generated IDs 201: description: Created 202: description : Accepted 404: description : Not Found 409: description : Conflict --- Add a child to a relationship """ errors = [] kwargs["require_child"] = True parent, relation = self.parse_args(**kwargs) json_response = request.get_jsonapi_payload() if not isinstance(json_response, dict): raise ValidationError("Invalid Object Type") data = json_response.get("data") if self.SAFRSObject.relationship.direction == MANYTOONE: # pylint: disable=len-as-condition if len(data) == 0: setattr(parent, self.SAFRSObject.relationship.key, None) if len(data) > 1: raise ValidationError( "Too many items for a MANYTOONE relationship", 403) child_id = data[0].get("id") child_type = data[0].get("type") if not child_id or not child_type: raise ValidationError("Invalid data payload", 403) if child_type != self.child_class.__name__: raise ValidationError("Invalid type", 403) child = self.child_class.get_instance(child_id) setattr(parent, self.SAFRSObject.relationship.key, child) result = [child] else: # direction is TOMANY => append the items to the relationship for item in data: if not isinstance(json_response, dict): raise ValidationError("Invalid data type") child_id = item.get("id", None) if child_id is None: errors.append("no child id {}".format(data)) safrs.log.error(errors) continue child = self.child_class.get_instance(child_id) if not child: errors.append("invalid child id {}".format(child_id)) safrs.log.error(errors) continue if not child in relation: relation.append(child) result = [item for item in relation] return jsonify({"data": result})
def delete(self, **kwargs): """ summary : Delete {child_name} from {cls.relationship.key} description : Delete {child_name} items from the {parent_name} {cls.relationship.key} "{direction}" relationship responses: 202 : description: Accepted 204 : description: Request fulfilled, nothing follows 200 : description: Success 403 : description: Forbidden 404 : description: Not Found ---- Remove an item from a relationship """ # pylint: disable=unused-variable # (parent is unused) parent, relation = self.parse_args(**kwargs) # No child id=> delete specified items from the relationship payload = request.get_jsonapi_payload() if not isinstance(payload, dict): raise ValidationError("Invalid Object Type") data = payload.get("data") if self.SAFRSObject.relationship.direction == MANYTOONE: # https://jsonapi.org/format/#crud-updating-to-one-relationships # We should only use patch to update # previous versions incorrectly implemented the jsonapi spec for updating manytoone relationships # keep things backwards compatible for now child = data if isinstance(data, list): if data and isinstance(data[0], dict): # invalid, try to fix it by deleting the firs item from the list safrs.log.warning( "Invalid Payload to delete from MANYTOONE relationship" ) data = data[0] if not isinstance(data, dict): raise ValidationError("Invalid data payload") child_id = data.get("id", None) child_type = data.get("type", None) if child_id is None or child_type is None: raise ValidationError("Invalid data payload", HTTPStatus.FORBIDDEN) if child_type != self.target._s_type: raise ValidationError("Invalid type", HTTPStatus.FORBIDDEN) child = self.target.get_instance(child_id) if child == relation and getattr(parent, self.rel_name, None) == child: # Delete the item from the many-to-one relationship delattr(parent, self.rel_name) else: safrs.log.warning("child not in relation") else: # https://jsonapi.org/format/#crud-updating-to-many-relationships children = data if not isinstance(data, list) or not children: raise ValidationError("Invalid data payload") for child in children: child_id = child.get("id", None) child_type = child.get("type", None) if not child_id or not child_type: raise ValidationError("Invalid data payload", HTTPStatus.FORBIDDEN) if child_type != self.target._s_type: raise ValidationError("Invalid type", HTTPStatus.FORBIDDEN) child = self.target.get_instance(child_id) if child in relation: relation.remove(child) else: safrs.log.warning( f"Item with id {child_id} not in relation") return make_response(jsonify({}), HTTPStatus.NO_CONTENT)
def patch(self, **kwargs): """ summary : Update {cls.relationship.key} description : Update the {parent_name} {cls.relationship.key} "{direction}" relationship responses: 200 : description : Accepted 204 : description : No Content 403: description : Forbidden 404 : description : Not Found 409 : description : Conflict ---- Update or create a relationship child item to be used to create or update one-to-many mappings but also works for many-to-many etc. # Updating To-One Relationships http://jsonapi.org/format/#crud-updating-to-one-relationships: A server MUST respond to PATCH requests to a URL from a to-one relationship link as described below The PATCH request MUST include a top-level member named data containing one of: a resource identifier object corresponding to the new related resource. null, to remove the relationship. """ changed = False parent, relation = self.parse_args(**kwargs) payload = request.get_jsonapi_payload() data = payload.get("data") relation = getattr(parent, self.rel_name) obj_args = {self.parent_object_id: parent.jsonapi_id} if isinstance(data, dict): # https://jsonapi.org/format/#crud-updating-to-one-relationships # server MUST respond to PATCH requests to a URL from a to-one relationship link as described below. # The PATCH request MUST include a top-level member named data containing one of: # a resource identifier object corresponding to the new related resource. # null, to remove the relationship. if self.SAFRSObject.relationship.direction != MANYTOONE: raise ValidationError( "Provide a list to PATCH a TOMANY relationship") child = self._parse_target_data(data) if getattr(parent, self.rel_name) != child: # change the relationship, i.e. add the child setattr(parent, self.rel_name, child) obj_args[self.child_object_id] = child.jsonapi_id changed = True elif isinstance( data, list ) and not self.SAFRSObject.relationship.direction == MANYTOONE: """ http://jsonapi.org/format/#crud-updating-to-many-relationships If a client makes a PATCH request to a URL from a to-many relationship link, the server MUST either completely replace every member of the relationship, return an appropriate error response if some resourcescan not be found or accessed, or return a 403 Forbidden response if complete replacement is not allowed by the server. """ # first remove all items, then append the new items # if the relationship has been configured with lazy="dynamic" # then it is a subclass of AppenderBaseQuery and # we should empty the relationship by setting it to [] # otherwise it is an instance of InstrumentedList and we have to empty it # (we could loop all items but this is slower for large collections) tmp_rel = [] for child_data in data: child = self._parse_target_data(child_data) tmp_rel.append(child) if isinstance(relation, sqlalchemy.orm.collections.InstrumentedList): relation[:] = tmp_rel else: setattr(parent, self.rel_name, tmp_rel) elif data is None and self.SAFRSObject.relationship.direction == MANYTOONE: # { data : null } //=> clear the relationship child = getattr(parent, self.SAFRSObject.relationship.key) if child: pass setattr(parent, self.rel_name, None) else: raise ValidationError( f'Invalid data object type "{type(data)}" for this "{self.SAFRSObject.relationship.direction}"" relationship' ) # Create the patch response # https://jsonapi.org/format/#crud-updating-responses # 200 OK # If a server accepts an update but also changes the resource(s) in ways other than those specified by the request # (for example, updating the updated-at attribute or a computed sha), it MUST return a 200 OK response. The response # document MUST include a representation of the updated resource(s) as if a GET request was made to the request URL. # A server MUST return a 200 OK status code if an update is successful, the client’s current attributes remain up to date, # and the server responds only with top-level meta data. In this case the server MUST NOT include a representation of the updated resource(s). # 204 No Content # If an update is successful and the server doesn’t update any attributes besides those provided, the server MUST return # either a 200 OK status code and response document (as described above) or a 204 No Content status code with no response document. if data is None: # item removed from relationship => 202 accepted data, status_code = {}, HTTPStatus.NO_CONTENT elif changed: return self.get(**obj_args) else: # Nothing changed, reflect the data data, status_code = {"data": data}, HTTPStatus.OK return make_response(jsonify(data), status_code)
def post(self, **kwargs): """ summary : Create {class_name} responses : 403: description : Forbidden 201: description: Created 202: description : Accepted 404: description : Not Found 409: description : Conflict --- http://jsonapi.org/format/#crud-creating Creating Resources A resource can be created by sending a POST request to a URL that represents a collection of resources. The request MUST include a single resource object as primary data. The resource object MUST contain at least a type member. If a relationship is provided in the relationships member of the resource object, its value MUST be a relationship object with a data member. The value of this key represents the linkage the new resource is to have. Response: 403: This implementation does not accept client-generated IDs 201: Created 202: Accepted 404: Not Found 409: Conflict Location Header identifying the location of the newly created resource Body : created object TODO: 409 Conflict A server MUST return 409 Conflict when processing a POST request to create a resource with a client-generated ID that already exists. A server MUST return 409 Conflict when processing a POST request in which the resource object’s type is not among the type(s) that constitute the collection represented by the endpoint. A server SHOULD include error details and provide enough information to recognize the source of the conflict. """ payload = request.get_jsonapi_payload() id = kwargs.get(self._s_object_id, None) if id is not None: # POSTing to an instance isn't jsonapi-compliant (https://jsonapi.org/format/#crud-creating-client-ids) # to do: modify Allow header raise ValidationError(f"POSTing to instance is not allowed {self}", status_code=HTTPStatus.METHOD_NOT_ALLOWED) # Create a new instance of the SAFRSObject data = payload.get("data") if data is None: raise ValidationError("Request contains no data") if isinstance(data, list): # http://springbot.github.io/json-api/extensions/bulk/ # We should verify that the bulk extension is requested # Accept it by default now if not request.is_bulk: safrs.log.warning( "Client sent a bulk POST but did not specify the bulk extension" ) instances = [] for item in data: instance = self._create_instance(item) instances.append(instance) resp_data = jsonify({"data": instances}) location = None else: instance = self._create_instance(data) # object_id is the endpoint parameter, for example "UserId" for a User SAFRSObject obj_args = {instance._s_object_id: instance.jsonapi_id} # Retrieve the object json and return it to the client resp_data = self.get(**obj_args) location = f"{url_for(self.endpoint)}{instance.jsonapi_id}" response = make_response(resp_data, HTTPStatus.CREATED) # Set the Location header to the newly created object(s) if location: response.headers["Location"] = location return response