Skip to content

[Elasticsearch] Documented snake_case → camelCase document field conversion never happens #8470

Description

@alinceDev

API Platform version(s) affected: 4.3.17 (4.x since MetadataAwareNameConverterPass was introduced)

Description

The Elasticsearch documentation states:

API Platform will automatically disable write operations and snake_case document fields will automatically be converted to camelCase object properties during serialization.

This conversion never happens. A content_key document field does not populate a $contentKey property — the property keeps its default value, silently.

InnerFieldsNameConverter does implement the documented behaviour, as its constructor default:

// src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php
public function __construct(private readonly NameConverterInterface $inner = new CamelCaseToSnakeCaseNameConverter())

but that default is unreachable. The service definition passes an argument that is always valid:

// src/Symfony/Bundle/Resources/config/elasticsearch.php
$services->set('api_platform.elasticsearch.name_converter.inner_fields', InnerFieldsNameConverter::class)
    ->args([service('api_platform.name_converter')->ignoreOnInvalid()]);

ignoreOnInvalid() would drop the argument — and let the CamelCaseToSnakeCaseNameConverter default apply — only if api_platform.name_converter did not exist. But MetadataAwareNameConverterPass defines and aliases it unconditionally, whether or not the user configured api_platform.name_converter:

// src/Symfony/Bundle/DependencyInjection/Compiler/MetadataAwareNameConverterPass.php
$container->setDefinition('api_platform.name_converter.metadata_aware', $definition);
$container->setAlias('api_platform.name_converter', 'api_platform.name_converter.metadata_aware');

Its only early return is !$container->hasDefinition('serializer.mapping.class_metadata_factory'), which never holds in a Symfony app with the serializer enabled.

Documents are therefore denormalized through MetadataAwareNameConverter, which is an identity converter for every property that has no #[SerializedName].

How to reproduce

Index content holds:

{ "_id": "abc", "_source": { "content_key": "my-key" } }

Resource:

#[ApiResource(stateOptions: new Options(index: 'content'))]
class Content
{
    public string $id = '';
    public string $contentKey = '';
}

GET /contents/abc returns "contentKey": "". Renaming the property to $content_key returns "my-key", confirming the document key is matched literally.

Reproduced directly against the service, without HTTP:

$hit = ['_id' => 'abc', '_source' => ['content_key' => 'my-key']];
$content = $container->get('api_platform.elasticsearch.normalizer.document')
    ->denormalize($hit, Content::class, DocumentNormalizer::FORMAT);

$content->contentKey; // '' — expected 'my-key'

api_platform.elasticsearch.name_converter.inner_fields receives HydraPrefixNameConverterMetadataAwareNameConverter, and denormalize('content_key', Content::class, 'elasticsearch') returns 'content_key'.

Second trap: #[SerializedName] does not work around it

The obvious workaround fails when the property carries serialization groups, which is the common case:

#[Groups(['content:read'])]
#[SerializedName('content_key')]
public string $contentKey = '';

MetadataAwareNameConverter::getCacheValueForAttributesMetadata() skips any attribute whose metadata has groups when the context has none — and the Elasticsearch denormalization context carries no groups:

if ($metadataGroups && !array_intersect($metadataGroups, $contextGroups) && !\in_array('*', $contextGroups, true)) {
    continue;
}

Normalization has no such group check, so the result is the worst of both: the JSON output field is renamed to content_key, while reading the document still fails.

Regarding #4053

#4053 reports this exact symptom and was closed as fixed, referencing tests/Functional/Elasticsearch/ReadTest.php. That test cannot catch it: the fixtures store camelCase document fields —

// tests/Fixtures/Elasticsearch/Fixtures/user.json
{ "firstName": "Kilian", "lastName": "Jornet", "registeredAt": "2009-09-01" }

— so it covers multi-word camelCase fields, not snake_case documents. Worth reopening, or covering with a fixture using snake_case keys.

Possible Solution

Either behaviour or documentation, depending on the intended convention:

  1. Restore the documented behaviour — inject serializer.name_converter.camel_case_to_snake_case into api_platform.elasticsearch.name_converter.inner_fields unless the user explicitly configured api_platform.name_converter (the compiler pass knows the difference: $container->hasAlias(...) before it sets its own alias), or expose a dedicated api_platform.elasticsearch.name_converter setting.
  2. Drop the claim — the same doc page also says "all fields should be lower case and should use camelCase for combining words", which contradicts the snake_case sentence and matches the fixtures. If camelCase documents are the intended convention, remove the sentence and the misleading InnerFieldsNameConverter constructor default.

Either way the current state is a silent data loss: a mismatched field yields an empty property, with no error.

Additional Context

Userland workaround, scoped to Elasticsearch so the API output stays camelCase:

services:
    api_platform.elasticsearch.name_converter.inner_fields:
        class: ApiPlatform\Elasticsearch\Serializer\NameConverter\InnerFieldsNameConverter
        autowire: false
        autoconfigure: false
        arguments: ['@serializer.name_converter.camel_case_to_snake_case']

PHP 8.4, Symfony 8.1, symfony/serializer 8.1.4, api-platform/elasticsearch 4.3.17, Elasticsearch 8.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions