Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow loading entities by any field. #334

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/api_url.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,38 @@ articles after a certain date:
curl https://example.com/api/articles?filter[created][value]=1417591992&filter[created][operator]=">="
```

## Loading by an alternate ID.
Some times you need to load an entity by an alternate ID that is not the regular
entity ID, for example a unique ID title. All that you need to do is provide the
alternate ID as the regular resource ID and inform that the passed in ID is not
the regular entity ID but a different field. To do so use the `loadByFieldName`
query parameter.

```
curl -H 'X-API-version: v1.5' https://www.example.org/articles/1234-abcd-5678-efg0?loadByFieldName=uuid
```

That will load the article node and output it as usual. Since every REST
resource object has a canonical URL (and we are using a different one) a _Link_
header will be added to the response with the canonical URL so the consumer can
use it in future requests.

```
HTTP/1.1 200 OK
Date: Mon, 22 Dec 2014 08:08:53 GMT
Content-Type: application/hal+json; charset=utf-8
...
Link: https://www.example.org/articles/12; rel="canonical"

{
...
}
```

The only requirement to use this feature is that the value for your
`loadByFieldName` field needs to be one of your exposed fields. It is also up to
you to make sure that that field is unique. Note that in case that more tha one
entity matches the provided ID the first record will be loaded.

## Working with authentication providers
Restful comes with ``cookie``, ``base_auth`` (user name and password in the HTTP
Expand Down
26 changes: 24 additions & 2 deletions plugins/restful/RestfulBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ protected static function addCidParams($keys) {
if (in_array($param, array(
'__application',
'filter',
'loadByFieldName',
'page',
'q',
'range',
Expand Down Expand Up @@ -316,6 +317,21 @@ public function getHttpHeaders() {
return $this->httpHeaders;
}

/**
* {@inheritdoc}
*/
public function addHttpHeaders($key, $value) {
$headers = $this->getHttpHeaders();
// Add a value to the (potentially) existing header.
$values = array();
if (!empty($headers[$key])) {
$values[] = $headers[$key];
}
$values[] = $value;
$header = implode(', ', $values);
$this->setHttpHeaders($key, $header);
}

/**
* Setter for $authenticationManager.
*
Expand Down Expand Up @@ -1383,13 +1399,15 @@ public static function getVersionFromRequest($path = NULL) {
* The path for the resource
* @param array $options
* Array of options as in url().
* @param boolean $version_string
* TRUE to add the version string to the URL. FALSE otherwise.
*
* @return string
* The fully qualified URL.
*
* @see url().
*/
public function versionedUrl($path = '', $options = array()) {
public function versionedUrl($path = '', $options = array(), $version_string = TRUE) {
// Make the URL absolute by default.
$options += array('absolute' => TRUE);
$plugin = $this->getPlugin();
Expand All @@ -1399,7 +1417,11 @@ public function versionedUrl($path = '', $options = array()) {
}

$base_path = variable_get('restful_hook_menu_base_path', 'api');
$url = $base_path . '/v' . $plugin['major_version'] . '.' . $plugin['minor_version'] . '/' . $plugin['resource'] . '/' . $path;
$url = $base_path;
if ($version_string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when don't we want to add the version string? I think we always want to refer to it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When having the URL in the Link header along with the X-API-Version: v1.5 header. It's sloppy and error prone (what if we have X-API-Version: v1.5 and https://www.example.org/api/v1.4/…?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, makes sense

$url .= '/v' . $plugin['major_version'] . '.' . $plugin['minor_version'];
}
$url .= '/' . $plugin['resource'] . '/' . $path;
return url(rtrim($url, '/'), $options);
}

Expand Down
24 changes: 12 additions & 12 deletions plugins/restful/RestfulDataProviderEFQ.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,28 +273,28 @@ abstract public function getList();
/**
* View an entity.
*
* @param int $entity_id
* The entity ID.
* @param $id
* The ID to load the entity.
*
* @return array
* Array with the public fields populated.
*
* @throws Exception
*/
abstract public function viewEntity($entity_id);
abstract public function viewEntity($id);

/**
* Get a list of entities based on a list of IDs.
*
* @param string $entity_ids_string
* Coma separated list of entities.
* @param string $ids_string
* Coma separated list of ids.
*
* @return array
* Array of entities, as passed to RestfulEntityBase::viewEntity().
*
* @throws RestfulBadRequestException
*/
abstract public function viewEntities($entity_ids_string);
abstract public function viewEntities($ids_string);

/**
* Create a new entity.
Expand All @@ -310,8 +310,8 @@ abstract public function createEntity();
/**
* Update an entity.
*
* @param $entity_id
* The entity ID.
* @param $id
* The ID to load the entity.
* @param bool $null_missing_fields
* Determine if properties that are missing form the request array should
* be treated as NULL, or should be skipped. Defaults to FALSE, which will
Expand All @@ -321,16 +321,16 @@ abstract public function createEntity();
* Array with the output of the new entity, passed to
* RestfulEntityInterface::viewEntity().
*/
abstract protected function updateEntity($entity_id, $null_missing_fields = FALSE);
abstract protected function updateEntity($id, $null_missing_fields = FALSE);

/**
* Delete an entity using DELETE.
*
* No result is returned, just the HTTP header is set to 204.
*
* @param $entity_id
* The entity ID.
* @param $id
* The ID to load the entity.
*/
abstract public function deleteEntity($entity_id);
abstract public function deleteEntity($id);

}
107 changes: 98 additions & 9 deletions plugins/restful/RestfulEntityBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public static function controllersInfo() {
// POST
\RestfulInterface::POST => 'createEntity',
),
'^(\d+,)*\d+$' => array(
'^.*$' => array(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems we are loosing an important validation here, although I'm not sure how smart we want to be about it. Any ideas? (anyway this isn't a blocker)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a big deal, since there's still validation when trying to load the entity.

\RestfulInterface::GET => 'viewEntities',
\RestfulInterface::HEAD => 'viewEntities',
\RestfulInterface::PUT => 'putEntity',
Expand Down Expand Up @@ -145,12 +145,12 @@ public function getList() {
/**
* {@inheritdoc}
*/
public function viewEntities($entity_ids_string) {
$entity_ids = array_unique(array_filter(explode(',', $entity_ids_string)));
public function viewEntities($ids_string) {
$ids = array_unique(array_filter(explode(',', $ids_string)));
$output = array();

foreach ($entity_ids as $entity_id) {
$output[] = $this->viewEntity($entity_id);
foreach ($ids as $id) {
$output[] = $this->viewEntity($id);
}
return $output;
}
Expand Down Expand Up @@ -270,7 +270,8 @@ protected function getQueryResultForAutocomplete() {
/**
* {@inheritdoc}
*/
public function viewEntity($entity_id) {
public function viewEntity($id) {
$entity_id = $this->getEntityIdByFieldId($id);
$request = $this->getRequest();

$cached_data = $this->getRenderedCache(array(
Expand Down Expand Up @@ -397,7 +398,7 @@ protected function getValueFromProperty(\EntityMetadataWrapper $wrapper, \Entity
protected function getValueFromFieldFormatter(\EntityMetadataWrapper $wrapper, \EntityMetadataWrapper $sub_wrapper, array $info) {
$property = $info['property'];

if (!field_info_field($property)) {
if (!static::propertyIsField($property)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

over abstraction?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that it makes semantic sense. A contributor or a module extending restful doesn't need to know about Field API to discern between an entity property and a field.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a big fan, but I can live with that ;)

// Property is not a field.
throw new \RestfulServerConfigurationException(format_string('@property is not a configurable field, so it cannot be processed using field API formatter', array('@property' => $property)));
}
Expand Down Expand Up @@ -564,7 +565,8 @@ public function deleteEntity($entity_id) {
/**
* {@inheritdoc}
*/
protected function updateEntity($entity_id, $null_missing_fields = FALSE) {
protected function updateEntity($id, $null_missing_fields = FALSE) {
$entity_id = $this->getEntityIdByFieldId($id);
$this->isValidEntity('update', $entity_id);

$wrapper = entity_metadata_wrapper($this->entityType, $entity_id);
Expand All @@ -581,7 +583,6 @@ protected function updateEntity($entity_id, $null_missing_fields = FALSE) {
return array($this->viewEntity($wrapper->getIdentifier()));
}


/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -1472,4 +1473,92 @@ public function clearResourceRenderedCacheEntity($id) {
$this->cacheInvalidate($cid);
}

/**
* Get the entity ID based on the ID provided in the request.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth giving an example, since it might not be obvious without this PR as context. maybe:

As any field may be used as the ID, we convert it to the numeric internal ID of the entity,

*
* As any field may be used as the ID, we convert it to the numeric internal
* ID of the entity
*
* @param mixed $id
* The provided ID.
*
* @throws RestfulBadRequestException
* @throws RestfulUnprocessableEntityException
*
* @return int
* The entity ID.
*/
protected function getEntityIdByFieldId($id) {
$request = $this->getRequest();
if (empty($request['loadByFieldName'])) {
// The regular entity ID was provided.
return $id;
}
$public_property_name = $request['loadByFieldName'];
// We need to get the internal field/property from the public name.
$public_fields = $this->getPublicFields();
if ((!$public_field_info = $public_fields[$public_property_name]) || empty($public_field_info['property'])) {
throw new \RestfulBadRequestException(format_string('Cannot load an entity using the field "@name"', array(
'@name' => $public_property_name,
)));
}
$query = $this->getEntityFieldQuery();
$query->range(0, 1);
// Find out if the provided ID is a Drupal field or an entity property.
if (static::propertyIsField($public_field_info['property'])) {
$query->fieldCondition($public_field_info['property'], $public_field_info['column'], $id);
}
else {
$query->propertyCondition($public_field_info['property'], $id);
}

// Execute the query and gather the results.
$result = $query->execute();
if (empty($result[$this->getEntityType()])) {
throw new RestfulUnprocessableEntityException(format_string('The entity ID @id by @name for @resource cannot be loaded.', array(
'@id' => $id,
'@resource' => $this->getPluginKey('label'),
'@name' => $public_property_name,
)));
}

// There is nothing that guarantees that there is only one result, since
// this is user input data. Return the first ID.
$entity_id = key($result[$this->getEntityType()]);

// REST requires a canonical URL for every resource.
$this->addHttpHeaders('Link', $this->versionedUrl($entity_id, array(), FALSE) . '; rel="canonical"');

return $entity_id;
}

/**
* Initialize an EntityFieldQuery (or extending class).
*
* @return \EntityFieldQuery
* The initialized query with the basics filled in.
*/
protected function getEntityFieldQuery() {
$query = new \EntityFieldQuery();
$query->entityCondition('entity_type', $this->getEntityType());
if ($bundle = $this->getBundle()) {
$query->entityCondition('bundle', $bundle);
}
return $query;
}

/**
* Checks if a given string represents a Field API field.
*
* @param string $name
* The name of the field/property.
*
* @return bool
* TRUE if it's a field. FALSE otherwise.
*/
public static function propertyIsField($name) {
$field_info = field_info_field($name);
return !empty($field_info);
}

}
10 changes: 10 additions & 0 deletions plugins/restful/RestfulInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ public function getHttpHeaders();
*/
public function setHttpHeaders($key, $value);

/**
* Add the a value to a multi-value HTTP header.
*
* @param string $key
* The HTTP header key.
* @param string $value
* The HTTP header value.
*/
public function addHttpHeaders($key, $value);

/**
* Determine if user can access the handler.
*
Expand Down
15 changes: 15 additions & 0 deletions tests/RestfulViewEntityTestCase.test
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ class RestfulViewEntityTestCase extends RestfulCurlBaseTestCase {
$this->assertEqual($result, $expected_result, 'Entity view has correct result for "main" resource v1.1 with empty entity reference.');


// Load an entity by an alternate field.
$entity4 = entity_create('entity_test', array('name' => 'main', 'uid' => $user1->uid));
$wrapper = entity_metadata_wrapper('entity_test', $entity4);
$text = $this->randomName();
$wrapper->text_single->set($text);
$wrapper->save();

$request = array('loadByFieldName' => 'text_single');
$result = $handler->get($text, $request);
$this->assertNotNull($result[0]);

// Make sure canonical header is added.
$headers = $handler->getHttpHeaders();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good stuff! :)

$this->assertEqual($headers['Link'], $handler->versionedUrl($wrapper->getIdentifier(), array(), FALSE) . '; rel="canonical"');

// v1.2 - "callback" and "process callback".
$handler = restful_get_restful_handler('main', 1, 2);
$base_expected_result['self'] = $handler->versionedUrl($id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ class RestfulTestArticlesResource__1_3 extends RestfulEntityBaseNode {
*/
public static function controllersInfo() {
$info = parent::controllersInfo();
$info['^(\d+,)*\d+$'][\RestfulInterface::GET] = array(
$info['^.*$'][\RestfulInterface::GET] = array(
'callback' => 'viewEntities',
'access callback' => 'accessViewEntityFalse',
);
$info['^(\d+,)*\d+$'][\RestfulInterface::HEAD] = array(
$info['^.*$'][\RestfulInterface::HEAD] = array(
'callback' => 'viewEntities',
'access callback' => 'accessViewEntityTrue',
);
Expand Down