English
English
Appearance
English
English
Appearance
A result page with facets, sorting and paging on top of the Search API. The call is made server side, so the public key never has to leave the backend and the response can be cached.
The code below is a Symfony application; the client itself has no framework dependency, so in another framework only the controller wrapper changes.
# config/services.yaml
services:
Metty\Client\MettyClient:
factory: ['Metty\Client\MettyClient', 'create']
arguments:
$publicKey: '%env(METTY_PUBLIC_KEY)%'The public key is enough for a result page — it only reads. The secret key belongs to the catalog import, not here.
<?php
namespace App\Controller;
use Metty\Client\Exception\ConfigurationException;
use Metty\Client\MettyClient;
use Metty\Client\Search\SearchQuery;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class SearchController extends AbstractController
{
private const PER_PAGE = 24;
public function __construct(private readonly MettyClient $metty) {}
#[Route('/search', name: 'search', methods: ['GET'])]
public function __invoke(Request $request): Response
{
$text = (string) $request->query->get('q', '');
$active = $request->query->all('filter');
try {
$query = SearchQuery::for($text)
->perPage(self::PER_PAGE)
->page(max(1, $request->query->getInt('page', 1)))
->withSections('facets', 'categories', 'suggestions');
$sort = (string) $request->query->get('sort', '');
if (in_array($sort, SearchQuery::SORTS, true)) {
$query = $query->sortBy($sort);
}
$category = (string) $request->query->get('category', '');
if ($category !== '') {
$query = $query->category($category);
}
foreach ($active as $field => $values) {
foreach ((array) $values as $value) {
$query = $query->facet((string) $field, (string) $value);
}
}
$response = $this->metty->search()->search($query);
} catch (ConfigurationException) {
return $this->redirectToRoute('search', ['q' => $text]);
}
return $this->render('search.html.twig', [
'results' => $response,
'active' => $active,
]);
}
}Building the query is inside the try as well, because the client refuses an invalid query before it sends anything, for example a page beyond the 200 result window — and the page comes straight from the URL. Returning to the first page is friendlier than a 500.
The sort is additionally checked against SearchQuery::SORTS, so a mistyped sort in the URL only loses the sort instead of the whole query.
The server marks matches with square brackets in the highlight field. On the client you only replace them with a tag and compute nothing:
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class HighlightExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('highlight', $this->highlight(...), ['is_safe' => ['html']]),
];
}
public function highlight(?string $marked, string $fallback): string
{
if ($marked === null) {
return htmlspecialchars($fallback);
}
return str_replace(['[', ']'], ['<mark>', '</mark>'], htmlspecialchars($marked));
}
}With the default autoconfigure: true Symfony registers the extension by itself, so the function is available in every template:
<h3>{{ highlight(product.highlight.name|default(null), product.name) }}</h3>Escaping has to happen before the brackets are replaced, otherwise HTML from a product name would end up in the page. The function returns the already escaped result, so it is marked is_safe and does not need |raw in the template.
facets is a list of fields, each carrying both a machine name and a display name:
{% for facet in results.facets %}
<fieldset>
<legend>{{ facet.label }}</legend>
{% for value in facet.values %}
<label>
<input type="checkbox" name="filter[{{ facet.field }}][]" value="{{ value.value }}"
{{ value.value in active[facet.field]|default([]) ? 'checked' }}>
{{ value.value }} <span>({{ value.count }})</span>
</label>
{% endfor %}
</fieldset>
{% endfor %}Multiple values of one field mean OR, values of different fields are combined with AND. The counts in the response are already recalculated according to the active filters, so they can be displayed as they are.
$lastPage = min(
$response->pages,
intdiv(SearchQuery::MAX_WINDOW, $response->perPage),
);The first 200 results are ranked, so the last available page follows from that boundary rather than from the total number of products found. If you need to offer more, narrow the selection with a category or a facet — deeper paging would return an unordered list anyway.
The public key is meant for reading, so the Search API can also be called from a frontend:
const params = new URLSearchParams({
key: PUBLIC_API_KEY,
q: query,
per_page: '24',
include: 'facets,categories'
})
for (const [field, values] of Object.entries(filters)) {
for (const value of values) {
params.append(`filter[${field}][]`, value)
}
}
const response = await fetch(`https://search.api.metty.eu/search?${params}`)
if (!response.ok) {
const { error } = await response.json()
throw new Error(error)
}
const { products, total, facets } = await response.json()Facets go under filter[…] and repeated values need the trailing brackets (filter[farba][]=modrá&filter[farba][]=čierna). Without them the server keeps only the last value.
If you call search on every keystroke, use autocomplete instead — it has a higher rate limit and a smaller response. Calls from the browser count toward the rate limit of the visitor's IP address, together with the widget if it runs on the same page.