Skip to content
Open
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
133 changes: 133 additions & 0 deletions features/elasticsearch/range_filter.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
@elasticsearch
Feature: Range filter on collections from Elasticsearch
In order to filter resources by a numeric or date range from Elasticsearch
As a client software developer
I need to query for resources matching range comparison operators

Scenario: Range filter using the gt operator
When I send a "GET" request to "/products?price%5Bgt%5D=20"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"type": "object",
"properties": {
"@context": {"pattern": "^/contexts/Product$"},
"@id": {"pattern": "^/products$"},
"@type": {"pattern": "^hydra:Collection$"},
"hydra:member": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"properties": {
"price": {"type": "integer", "minimum": 21}
},
"required": ["price"]
}
}
}
}
"""

Scenario: Range filter combining gte and lte (bounded range)
When I send a "GET" request to "/products?price%5Bgte%5D=10&price%5Blte%5D=30"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"type": "object",
"properties": {
"@context": {"pattern": "^/contexts/Product$"},
"@id": {"pattern": "^/products$"},
"hydra:member": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"properties": {
"price": {"type": "integer", "minimum": 10, "maximum": 30}
},
"required": ["price"]
}
}
}
}
"""

Scenario: Range filter using the lt operator
When I send a "GET" request to "/products?price%5Blt%5D=20"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"type": "object",
"properties": {
"hydra:member": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"properties": {
"price": {"type": "integer", "maximum": 19}
},
"required": ["price"]
}
}
}
}
"""

Scenario: Range filter on a date property using gte
When I send a "GET" request to "/products?releaseDate%5Bgte%5D=2023-01-01"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"type": "object",
"properties": {
"hydra:member": {
"type": "array",
"minItems": 2,
"maxItems": 2
}
}
}
"""

Scenario: Range filter ignores unknown operators
When I send a "GET" request to "/products?price%5Bgte%5D=30&price%5Bunknown%5D=99"
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be valid according to this schema:
"""
{
"type": "object",
"properties": {
"hydra:member": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"properties": {
"price": {"type": "integer", "minimum": 30}
},
"required": ["price"]
}
}
}
}
"""
137 changes: 137 additions & 0 deletions src/Elasticsearch/Filter/RangeFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Elasticsearch\Filter;

/**
* The range filter allows to find resources matching a numeric or date [range](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html)
* on the given properties.
*
* Syntax: `?property[<operator>]=value` where `<operator>` is one of `gt`, `gte`, `lt`, `lte`.
* Operators can be combined to express bounded ranges.
*
* <div data-code-selector>
*
* ```php
* <?php
* // api/src/Entity/Book.php
* use ApiPlatform\Metadata\ApiFilter;
* use ApiPlatform\Metadata\ApiResource;
* use ApiPlatform\Elasticsearch\Filter\RangeFilter;
*
* #[ApiResource]
* #[ApiFilter(RangeFilter::class, properties: ['price'])]
* class Book
* {
* // ...
* }
* ```
*
* ```yaml
* # config/services.yaml
* services:
* book.range_filter:
* parent: 'api_platform.elasticsearch.range_filter'
* arguments: [ { price: ~ } ]
* tags: [ 'api_platform.filter' ]
* # The following are mandatory only if a _defaults section is defined with inverted values.
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
* autowire: false
* autoconfigure: false
* public: false
*
* # api/config/api_platform/resources.yaml
* resources:
* App\Entity\Book:
* - operations:
* ApiPlatform\Metadata\GetCollection:
* filters: ['book.range_filter']
* ```
*
* ```xml
* <?xml version="1.0" encoding="UTF-8" ?>
* <!-- api/config/services.xml -->
* <?xml version="1.0" encoding="UTF-8" ?>
* <container
* xmlns="http://symfony.com/schema/dic/services"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="http://symfony.com/schema/dic/services
* https://symfony.com/schema/dic/services/services-1.0.xsd">
* <services>
* <service id="book.range_filter" parent="api_platform.elasticsearch.range_filter">
* <argument type="collection">
* <argument key="price"/>
* </argument>
* <tag name="api_platform.filter"/>
* </service>
* </services>
* </container>
* <!-- api/config/api_platform/resources.xml -->
* <resources
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
* <resource class="App\Entity\Book">
* <operations>
* <operation class="ApiPlatform\Metadata\GetCollection">
* <filters>
* <filter>book.range_filter</filter>
* </filters>
* </operation>
* </operations>
* </resource>
* </resources>
* ```
*
* </div>
*
* Given that the collection endpoint is `/books`, you can filter books priced between 10 and 50
* with the following query: `/books?price[gte]=10&price[lte]=50`.
*
* Unknown operators are ignored, so only `gt`, `gte`, `lt` and `lte` ever reach the underlying query.
*
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html
*
* @author Saifallah Azzabi <seifallah.azzabi@gmail.com>
*/
final class RangeFilter extends AbstractSearchFilter
{
public const GT = 'gt';

public const GTE = 'gte';

public const LT = 'lt';

public const LTE = 'lte';

/**
* {@inheritdoc}
*/
protected function getQuery(string $property, array $values, ?string $nestedPath): array
{
$rangeQuery = ['range' => [$property => []]];

foreach ($values as $operator => $value) {
if (\in_array($operator, [self::GT, self::GTE, self::LT, self::LTE], true)) {
$rangeQuery['range'][$property][$operator] = $value;
}
}

if (null !== $nestedPath) {
return ['nested' => ['path' => $nestedPath, 'query' => $rangeQuery]];
}

return $rangeQuery;
}
}
7 changes: 7 additions & 0 deletions src/Symfony/Bundle/Resources/config/elasticsearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ApiPlatform\Elasticsearch\Extension\SortFilterExtension;
use ApiPlatform\Elasticsearch\Filter\MatchFilter;
use ApiPlatform\Elasticsearch\Filter\OrderFilter;
use ApiPlatform\Elasticsearch\Filter\RangeFilter;
use ApiPlatform\Elasticsearch\Filter\TermFilter;
use ApiPlatform\Elasticsearch\Metadata\Resource\Factory\ElasticsearchProviderResourceMetadataCollectionFactory;
use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer;
Expand Down Expand Up @@ -93,6 +94,12 @@

$services->alias(MatchFilter::class, 'api_platform.elasticsearch.match_filter');

$services->set('api_platform.elasticsearch.range_filter', RangeFilter::class)
->abstract()
->parent('api_platform.elasticsearch.search_filter');

$services->alias(RangeFilter::class, 'api_platform.elasticsearch.range_filter');

$services->set('api_platform.elasticsearch.order_filter', OrderFilter::class)
->abstract()
->args([
Expand Down
32 changes: 32 additions & 0 deletions tests/Fixtures/Elasticsearch/Fixtures/product.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[
{
"id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a601",
"name": "Bookmark",
"price": 10,
"releaseDate": "2020-01-01"
},
{
"id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a602",
"name": "Compass",
"price": 20,
"releaseDate": "2021-06-15"
},
{
"id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a603",
"name": "Notebook",
"price": 30,
"releaseDate": "2022-03-10"
},
{
"id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a604",
"name": "Headlamp",
"price": 40,
"releaseDate": "2023-08-20"
},
{
"id": "5fa1f0d8-c4ad-4d9e-9f37-3b7c98e1a605",
"name": "Tent",
"price": 50,
"releaseDate": "2024-01-01"
}
]
20 changes: 20 additions & 0 deletions tests/Fixtures/Elasticsearch/Mappings/product.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"name": {
"type": "keyword"
},
"price": {
"type": "integer"
},
"releaseDate": {
"type": "date",
"format": "yyyy-MM-dd"
}
},
"dynamic": "strict"
}
}
39 changes: 39 additions & 0 deletions tests/Fixtures/Elasticsearch/Model/Product.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\Elasticsearch\Model;

use ApiPlatform\Elasticsearch\Filter\RangeFilter;
use ApiPlatform\Elasticsearch\State\Options;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(normalizationContext: ['groups' => ['product:read']], stateOptions: new Options(index: 'product'))]
#[ApiFilter(RangeFilter::class, properties: ['price', 'releaseDate'])]
class Product
{
#[Groups(['product:read'])]
#[ApiProperty(identifier: true)]
public ?string $id = null;

#[Groups(['product:read'])]
public ?string $name = null;

#[Groups(['product:read'])]
public ?int $price = null;

#[Groups(['product:read'])]
public ?\DateTimeImmutable $releaseDate = null;
}
Loading