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.
final class SearchController
{
private const PER_PAGE = 24;
public function __construct(private readonly MettyClient $metty) {}
public function __invoke(Request $request): Response
{
$query = SearchQuery::for($request->query->get('q', ''))
->perPage(self::PER_PAGE)
->page(max(1, $request->query->getInt('page', 1)))
->withSections('facets', 'categories', 'suggestions');
if ($sort = $request->query->get('sort')) {
$query = $query->sortBy($sort);
}
if ($category = $request->query->get('category')) {
$query = $query->category($category);
}
foreach ($request->query->all('filter') as $field => $values) {
foreach ((array) $values as $value) {
$query = $query->facet($field, $value);
}
}
try {
$response = $this->metty->search()->search($query);
} catch (ConfigurationException) {
return new RedirectResponse($this->urlFor(['q' => $request->query->get('q', '')]));
}
return $this->render('search.html.twig', ['results' => $response]);
}
}ConfigurationException is caught because a request beyond the 200 result window is invalid. Returning to the first page is friendlier than showing an error.
The server marks matches with square brackets in the highlight field. On the client you only replace them with a tag and compute nothing:
function highlight(?string $marked, string $fallback): string
{
if ($marked === null) {
return htmlspecialchars($fallback);
}
$escaped = htmlspecialchars($marked);
return str_replace(['[', ']'], ['<mark>', '</mark>'], $escaped);
}<h3>{{ highlight(product.highlight.name ?? null, product.name)|raw }}</h3>Escaping has to happen before the brackets are replaced, otherwise HTML from a product name would end up in the page.
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(`${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()Send repeated values with brackets (farba[]=modrá&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.