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

On-demand REST resource embedding #1741

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
185 changes: 185 additions & 0 deletions doc/specifications/proposed/rest_resource_embedding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# REST resource embedding

Rest embedding allows an API consumer to request references from the
response to be embedded, in order to avoid extra REST calls.

## Example

A location response contains a reference to the content item's main location:

```
curl -X GET http://localhost:8000/api/ezp/v2/content/objects/1
```

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Content media-type="application/vnd.ez.api.ContentInfo+xml" href="/api/ezp/v2/content/objects/1" remoteId="9459d3c29e15006e45197295722c7ade" id="1">
<ContentType media-type="application/vnd.ez.api.ContentType+xml" href="/api/ezp/v2/content/types/1"/>
<Name>eZ Platform</Name>
<Versions media-type="application/vnd.ez.api.VersionList+xml" href="/api/ezp/v2/content/objects/1/versions"/>
<CurrentVersion media-type="application/vnd.ez.api.Version+xml" href="/api/ezp/v2/content/objects/1/currentversion"/>
<Section media-type="application/vnd.ez.api.Section+xml" href="/api/ezp/v2/content/sections/1"/>
<MainLocation media-type="application/vnd.ez.api.Location+xml" href="/api/ezp/v2/content/locations/1/2"/>
<Locations media-type="application/vnd.ez.api.LocationList+xml" href="/api/ezp/v2/content/objects/1/locations"/>
<Owner media-type="application/vnd.ez.api.User+xml" href="/api/ezp/v2/user/users/14"/>
<lastModificationDate>2015-11-30T13:10:46+00:00</lastModificationDate>
<publishedDate>2015-11-30T13:10:46+00:00</publishedDate>
<mainLanguageCode>eng-GB</mainLanguageCode>
<currentVersionNo>9</currentVersionNo>
<alwaysAvailable>true</alwaysAvailable>
<ObjectStates media-type="application/vnd.ez.api.ContentObjectStates+xml" href="/api/ezp/v2/content/objects/1/objectstates"/>
</Content>
```

By adding an `X-eZ-Embed-Value` header to the request, we can get the
main location object embedded into the response:

```
curl -X GET http://localhost:8000/api/ezp/v2/content/objects/1 -H 'x-ez-embed-value: Content.MainLocation'
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be possible to instead use query param and align with JSON API? Or are there some implementation details that makes it hard to use query param?

As in:

curl -X GET http://localhost:8000/api/ezp/v2/content/objects/1?include=Content.MainLocation

Copy link
Contributor

Choose a reason for hiding this comment

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

(using header will make it hard to cache for instance)

```

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Content media-type="application/vnd.ez.api.ContentInfo+xml" href="/api/ezp/v2/content/objects/1" remoteId="9459d3c29e15006e45197295722c7ade" id="1">
<ContentType media-type="application/vnd.ez.api.ContentType+xml" href="/api/ezp/v2/content/types/1"/>
<Name>eZ Platform</Name>
<Versions media-type="application/vnd.ez.api.VersionList+xml" href="/api/ezp/v2/content/objects/1/versions"/>
<CurrentVersion media-type="application/vnd.ez.api.Version+xml" href="/api/ezp/v2/content/objects/1/currentversion"/>
<Section media-type="application/vnd.ez.api.Section+xml" href="/api/ezp/v2/content/sections/1"/>
<MainLocation media-type="application/vnd.ez.api.Location+xml" href="/api/ezp/v2/content/locations/1/2">
<id>2</id>
<priority>0</priority>
<hidden>false</hidden>
<invisible>false</invisible>
<ParentLocation media-type="application/vnd.ez.api.Location+xml" href="/api/ezp/v2/content/locations/1"/>
<pathString>/1/2/</pathString>
<depth>1</depth>
<childCount>8</childCount>
<remoteId>f3e90596361e31d496d4026eb624c983</remoteId>
<Children media-type="application/vnd.ez.api.LocationList+xml" href="/api/ezp/v2/content/locations/1/2/children"/>
<Content media-type="application/vnd.ez.api.Content+xml" href="/api/ezp/v2/content/objects/1"/>
<sortField>PRIORITY</sortField>
<sortOrder>ASC</sortOrder>
<UrlAliases media-type="application/vnd.ez.api.UrlAliasRefList+xml" href="/api/ezp/v2/content/locations/1/2/urlaliases"/>
<ContentInfo media-type="application/vnd.ez.api.ContentInfo+xml" href="/api/ezp/v2/content/objects/1"/>
</MainLocation>
<Locations media-type="application/vnd.ez.api.LocationList+xml" href="/api/ezp/v2/content/objects/1/locations"/>
<Owner media-type="application/vnd.ez.api.User+xml" href="/api/ezp/v2/user/users/14"/>
<lastModificationDate>2015-11-30T13:10:46+00:00</lastModificationDate>
<publishedDate>2015-11-30T13:10:46+00:00</publishedDate>
<mainLanguageCode>eng-GB</mainLanguageCode>
<currentVersionNo>9</currentVersionNo>
<alwaysAvailable>true</alwaysAvailable>
<ObjectStates media-type="application/vnd.ez.api.ContentObjectStates+xml" href="/api/ezp/v2/content/objects/1/objectstates"/>
</Content>
```

### The `X-eZ-Embed-Value` request header

Which resources must be embedded is specified using this request header.
It accepts several resources separated by commas:

`X-eZ-Embed-Value: Content.MainLocation,Content.Owner.Groups`

A resource is referenced by its "path" from the root of the response.
Resources from an embedded resource can also be embedded:
Copy link
Member

Choose a reason for hiding this comment

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

Do we need some form of recursion protection? I mean, to avoid intentional or accidental DOS-ing with e.g. Content.MainLocation.Content.MainLocation.Content.MainLocation etc? At least we can't have an "expand all" feature.

Copy link
Member

Choose a reason for hiding this comment

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

Uh, this is the duplicate object prevention mentioned below, isn't 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.

Nope, unfortunately it is not.

Yes, there is a possibility of DOSing ourselves here. I did think of such a protection, but it's not on my todo list yet. Suggestions welcome !


- `Content.MainLocation`
- `Content.ContentType`
- `Content.Owner.Groups`

### Permissions
If the user doesn't have the required permissions to load an embedded
Copy link
Contributor

Choose a reason for hiding this comment

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

could we have something a bit more explicit instead ? Also, it's not written anywhere but I guess the same will happen if for instance the embedded object has been removed (for instance the Owner of a Content Item) ? Ideally, the REST consumer should be able to differentiate those 2 cases. For instance, an attribute on the link would work well I think

Copy link
Member Author

@bdunogier bdunogier Sep 1, 2016

Choose a reason for hiding this comment

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

An attribute on the link is easy. Any preferred format/contents ?

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a TODO in any case.

object, the response will be untouched, and no error will be thrown.

### HTTP Caching
Responses will vary based on the embedded responses.

Thanks to HTTP cache multi-tagging, customized responses will expire as
expected: each embedded object will tag the response with the HTTP cache
tags it requires.

### Implementation

#### Resource links generation in value object visitors
Value object visitors don't use the router directly anymore to generate
links to resources. Instead, they build and visit a `ResourceRouteReference`
object with the name of the route and the route's parameters:

```php
class LocationValueObjectVisitor extends ValueObjectVisitor
{
public function visit($generator, $visitor, $location)
{
// ...

$generator->startObjectElement$generator->startObjectElement('Content');
$visitor->visitValueObject(
new ResourceRouteReference(
'ezpublish_rest_loadContent',
['contentId' => $location->contentInfo->contentId]
);
$generator->endObjectElement('Content');
}
}
```

#### The `ResourceRouteReference` value object visitor
This object's visitor extends the `RestResourceLink` visitor.

It uses the router to generate a link based on the `RestResourceLink`
properties, and invokes the parent's `visit()` method.

#### The `RestResourceLink` value object visitor
It first generate an `href` attribute, respecting the REST output that
existed before this feature.

It then uses a `PathExpansionChecker` to test if the current generator path,
returned by the `Generator::getStackPath()` method, is requested for expansion.
A `RequestHeaderPathExpansionChecker` uses the request to test if expansion
is needed.

If it is, a `ValueReferenceLoader` loads the referenced
value object. The returned value object it is visited and added to generated
output, inside the current object element.

#### The `ExpansionGenerator`
The `RestResourceLink` visitor passes an `ExpansionGenerator` when visiting
the loaded value object.

This OutputGenerator decorates the actual (XML or JSON) generator. It will
skip the first objectElement and its attributes generated for the embedded
object, in order to avoid duplicate nodes. In the example above, the `LocationList`
Copy link
Member

Choose a reason for hiding this comment

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

You did indeed mean "example above" as written, yes? I'm confused - why would Children include LocationList in the first place? (necessitating special steps for avoiding it here)

Copy link
Member Author

@bdunogier bdunogier Sep 1, 2016

Choose a reason for hiding this comment

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

It would because of how the visitors / generators work.

Embedding works inside an objectElement (<Content in an XML output) .

The RestResourceReference is created and visited insite that element, for instance Content that is visited will first add the href attribute, and then visit, if applicable, the referenced object.

The embedded object's visitor will add its own root tag. For a Content, it will add a new Content object Element, making you end up with:

<Location>
  <!-- that's the first objetElement, where we optionally embed the reference -->
  <Content href="...">
    <!-- this was generated by the Content visitor -->
    <Content href="...">
      <name>...</name>
    </Content>
  </Content>
  <Children href="/api/ezp/v2/content/locations/1/2/children">
    <LocationList href="/api/ezp/v2/content/locations/1/2/children">
      <Location... />
    </LocationList>
  </Children>
</Location>

The ExpansionGenerator skips the root objectElement of embedded visited objects, as well as the attributes (avoids double "href" for instance).

object is skipped:

```
<?xml version="1.0" encoding="UTF-8"?>
<Location media-type="application/vnd.ez.api.Location+xml" href="/api/ezp/v2/content/locations/1/2">
<!-- ... -->
<Children media-type="application/vnd.ez.api.LocationList+xml" href="/api/ezp/v2/content/locations/1/2/children">
<!-- This is skipped -->
<LocationList media-type="application/vnd.ez.api.LocationList+xml" href="/api/ezp/v2/content/locations/1/2/children">
<Location media-type="application/vnd.ez.api.Location+xml" href="/api/ezp/v2/content/locations/1/2/55"/>
</LocationList>
</Children>
</Location>
```

### Loading of references
References are loaded by the `ControllerUriValueLoader`. Given a REST
resource URI (`/api/ezp/v2/content/objects/1`), it will determine and call
the REST controller action for that URI.

This implementation ensures that any REST resource that has a controller
can be embedded without requiring any extra development.

Resources that have multiple representations, such as Content/ContentInfo,
will use the optional media-type from the RestResourceReference to embed
the expected representation.

### HTTP caching
- Requires HTTP cache multi-tagging
- Response must vary on `x-ez-embed-value`
- Response must be tagged with all of the included items
Since the controllers are used to expand objects, the required cache
headers should be included automatically (to check)
12 changes: 12 additions & 0 deletions eZ/Bundle/EzPublishRestBundle/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ services:
arguments:
- "@ezpublish_rest.output.generator.json"
- "@ezpublish_rest.output.value_object_visitor.dispatcher"
- "@router"
tags:
- { name: ezpublish_rest.output.visitor, regexps: ezpublish_rest.output.visitor.json.regexps }

Expand Down Expand Up @@ -403,3 +404,14 @@ services:
parent: hautelook.router.template
calls:
- [ setOption, [ strict_requirements, ~ ] ]

ezpublish_rest.controller_uri_value_loader:
class: eZ\Publish\Core\REST\Server\Output\PathExpansion\ValueLoaders\ControllerUriValueLoader
arguments:
- '@router'
- '@controller_resolver'

ezpublish_rest.unique_controller_uri_value_loader:
class: eZ\Publish\Core\REST\Server\Output\PathExpansion\ValueLoaders\UniqueUriValueLoader
arguments:
- '@ezpublish_rest.controller_uri_value_loader'
Original file line number Diff line number Diff line change
Expand Up @@ -859,3 +859,28 @@ services:
class: "%ezpublish_rest.output.value_object_visitor.ViewInput.class%"
tags:
- { name: ezpublish_rest.output.value_object_visitor, type: eZ\Publish\Core\REST\Client\Values\ViewInput }

ezpublish_rest.output.value_object_visitor.internal.resource_link:
parent: ezpublish_rest.output.value_object_visitor.base
class: 'eZ\Publish\Core\REST\Server\Output\ValueObjectVisitor\ResourceLink'
tags:
- { name: ezpublish_rest.output.value_object_visitor, type: 'eZ\Publish\Core\REST\Server\Values\ResourceLink' }
arguments:
- '@ezpublish_rest.unique_controller_uri_value_loader'
- '@ezpublish_rest.output.path_expansion_checker.request_header'
- '@ezpublish_rest.output.value_object_visitor.dispatcher'

ezpublish_rest.output.path_expansion_checker.request_header:
class: 'eZ\Publish\Core\REST\Server\Output\PathExpansion\RequestHeaderPathExpansionChecker'
calls:
- [setRequestStack, ['@request_stack']]

ezpublish_rest.output.value_object_visitor.internal.resource_route_reference:
parent: ezpublish_rest.output.value_object_visitor.base
class: 'eZ\Publish\Core\REST\Server\Output\ValueObjectVisitor\ResourceRouteReference'
tags:
- { name: ezpublish_rest.output.value_object_visitor, type: 'eZ\Publish\Core\REST\Server\Values\ResourceRouteReference' }
arguments:
- '@ezpublish_rest.unique_controller_uri_value_loader'
- '@ezpublish_rest.output.path_expansion_checker.request_header'
- '@ezpublish_rest.output.value_object_visitor.dispatcher'
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/**
* @copyright Copyright (C) eZ Systems AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
namespace eZ\Bundle\EzPublishRestBundle\Tests\Functional;

use eZ\Bundle\EzPublishRestBundle\Tests\Functional\TestCase as RESTFunctionalTestCase;
use eZ\Publish\Core\REST\Common\Tests\AssertXmlTagTrait;

class ResourceEmbeddingTest extends RESTFunctionalTestCase
{
use AssertXmlTagTrait;

public function testEmbed()
{
$request = $this->createHttpRequest('GET', '/api/ezp/v2/content/objects/1');
$request->addHeader('x-ez-embed-value: Content.MainLocation,Content.Owner.Section');

$response = $this->sendHttpRequest($request);

self::assertHttpResponseCodeEquals($response, 200);

$doc = new \DOMDocument();
$doc->loadXML($response->getContent());

$this->assertXPath($doc, '/Content/MainLocation/id');
$this->assertXPath($doc, '/Content/Owner/name');
$this->assertXPath($doc, '/Content/Owner/Section/sectionId');
}

protected function assertXPath(\DOMDocument $document, $xpathExpression)
{
$xpath = new \DOMXPath($document);

$this->assertTrue(
$xpath->evaluate("boolean({$xpathExpression})"),
"XPath expression '{$xpathExpression}' resulted in an empty node set."
);
}
}
27 changes: 27 additions & 0 deletions eZ/Publish/Core/REST/Common/Output/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,31 @@ protected function checkEnd($type, $data)
* @return mixed
*/
abstract public function serializeBool($boolValue);

/**
* Returns a string representation of the current stack of the document.
*
* Example: "Location.ContentInfo'.
* Elements of type 'attribute', 'document' and 'list' are ignored.
*
* @return string
*/
public function getStackPath()
{
$relevantNodes = array_filter(
$this->stack,
function ($value) {
return !in_array($value[0], ['attribute', 'document', 'list']);
}
);

$names = array_map(
function ($value) {
return $value[1];
},
$relevantNodes
);

return implode('.', $names);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use eZ\Publish\Core\REST\Common\Output\ValueObjectVisitor;
use eZ\Publish\Core\REST\Common\Output\Generator;
use eZ\Publish\Core\REST\Common\Output\Visitor;
use eZ\Publish\Core\REST\Server\Values\ResourceRouteReference;

/**
* ContentObjectStates value object visitor.
Expand All @@ -36,17 +37,15 @@ public function visit(Visitor $visitor, Generator $generator, $data)

foreach ($data->states as $state) {
$generator->startObjectElement('ObjectState');
$generator->startAttribute(
'href',
$this->router->generate(
$visitor->visitValueObject(
new ResourceRouteReference(
'ezpublish_rest_loadObjectState',
array(
[
'objectStateGroupId' => $state->groupId,
'objectStateId' => $state->objectState->id,
)
]
)
);
$generator->endAttribute('href');
$generator->endObjectElement('ObjectState');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,26 @@ public function addVisitor($visitedClassName, ValueObjectVisitor $visitor)
*
* @return mixed
*/
public function visit($data)
public function visit($data, Generator $generator = null, Visitor $visitor = null)
{
if (!is_object($data)) {
throw new Exceptions\InvalidTypeException($data);
}
$checkedClassNames = array();

if ($visitor === null) {
$visitor = $this->outputVisitor;
}

if ($generator === null) {
$generator = $this->outputGenerator;
}

$className = get_class($data);
do {
$checkedClassNames[] = $className;
if (isset($this->visitors[$className])) {
return $this->visitors[$className]->visit($this->outputVisitor, $this->outputGenerator, $data);
return $this->visitors[$className]->visit($visitor, $generator, $data);
}
} while ($className = get_parent_class($className));

Expand Down
9 changes: 6 additions & 3 deletions eZ/Publish/Core/REST/Common/Output/Visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,15 @@ public function visit($data)
*
* @return mixed
*/
public function visitValueObject($data)
public function visitValueObject($data, Generator $generator = null, Visitor $visitor = null)
{
$this->valueObjectVisitorDispatcher->setOutputGenerator($this->generator);
$this->valueObjectVisitorDispatcher->setOutputVisitor($this);

return $this->valueObjectVisitorDispatcher->visit($data);
return $this->valueObjectVisitorDispatcher->visit(
$data,
$generator ?: $this->generator,
$visitor ?: $this
);
}

/**
Expand Down
Loading