Skip to content

feat(metadata): several resources on a shared class - #8840

Draft
soyuka wants to merge 1 commit into
api-platform:mainfrom
soyuka:feat/dynamic-resource-shared-class
Draft

soyuka wants to merge 1 commit into
api-platform:mainfrom
soyuka:feat/dynamic-resource-shared-class

Conversation

@soyuka

@soyuka soyuka commented Nov 12, 2026

Copy link
Copy Markdown
Member
Q A
Branch? main
Tickets Refs #8791, alternative to #8813
License MIT
Doc PR -

Alternative to #8813, same functional test (plus a second model and a nested case). Instead of putting a name that is not a class in Operation::class and guarding every reflection / getManagerForClass() call, we keep a real class (DynamicRecord) and declare one ApiResource per dynamic model on it, distinguished by shortName. Several ApiResource on one class is already supported, so metadata, routes, getResourceFromIri() and Doctrine factories work untouched, no class_exists needed.

Two things were missing:

  • property factories could not tell which resource they are called for: operation_name in the factory options is now the operation of the object/schema being built (it was set but never read), root_operation_name stays the source of the groups. SchemaFactory passes it too.
  • choosing the ApiResource from an object on a shared class: ResourceAwareInterface::getResource() (the shortName) + ResourceMetadataCollection::getOperationForObject(), used by AbstractItemNormalizer, the JSON-LD ItemNormalizer and the Symfony IriConverter.

Not covered yet: nested JSON Schema (ResourceMetadataTrait::findOperation takes the first resource), Laravel IriConverter, JSON:API getRelationship, denormalization / instantiation. The property cache also gets one entry per operation for root items now, to be measured.

Alternative to api-platform#8813 for api-platform#8791: keep a real class, carry one
ApiResource per dynamic model and pick it through
ResourceAwareInterface; property factories receive the object's
operation_name next to root_operation_name.
@alanpoulain

Copy link
Copy Markdown
Member

Keeping a real class does avoid all the class_exists() guards from #8813, and ResourceAwareInterface + getOperationForObject() fixes the nested-object case, which #8813 doesn't handle yet.

My concern is that with a shared class, resource_class now has a single value for every dynamic model. shortName becomes the real identity, but any API that only receives string $resourceClass still sees one resource: create($resourceClass)->getOperation() or [0] always resolves to the first declared model. This PR fixes the paths where the object or the operation name is available. These ones still only get the class:

Location Code Possible effect
JsonLd/ContextBuilder::getResourceContextUri() create($resourceClass)[0]->getShortName() @context: /contexts/DynamicFaq for a DynamicNews item when the class-based path is used
JsonLd/ContextBuilder::getResourceContext() getOperation(null, false, true) /contexts/DynamicNews built from the wrong resource
JsonLd/Serializer/ItemNormalizer (output operation, resolveType() fallback) create($resourceClass)->getOperation() Wrong @type or output operation without an operation in context (only the normalize() branch was patched)
Hydra/Serializer/CollectionNormalizer, CollectionObjectNormalizer getIriFromResource($resourceClass, …) Wrong collection @id without an operation in context
JsonApi/Serializer/ObjectNormalizer, JsonApiProvider, SparseFieldsetParameterProvider create($resourceClass)->getOperation()->getShortName() JSON:API type / sparse fieldsets resolve to the first model
JsonApi/Serializer/ItemNormalizerTrait::getRelationship… getIriFromResource($resourceClass, …) Wrong relationship IRIs (already listed as not covered)
Metadata/IdentifiersExtractor $operation ??= create($resourceClass)->getOperation(…) Identifiers come from the first resource's links
Laravel/Routing/IriConverter same pattern Not patched (already listed)
ResourceClassResolver getObjectClass() + is_a() Always returns DynamicRecord and can never tell which model an object belongs to
Cached{PropertyMetadata,PropertyNameCollection}Factory key = [$resourceClass, $property, $options] Correct only when operation_name is in $options. Callers that don't pass it (validator, filters, anything outside AbstractItemNormalizer / SchemaFactory) get the union of properties or the wrong set
Symfony Validator constraint metadata per PHP class Constraints can't differ per model, which #8791 asks for
Doctrine extensions / filters $resourceClass Same entity for every model (fine with a generic JSON-column entity), but scoping rows by model has to rely on $operation->getShortName() and not on $resourceClass

Some of these are inferred from reading the code and haven't been tested. The cheapest checks would be GET /contexts/DynamicNews and normalizing a DynamicNews without an operation in context.

Fixing this approach means passing the operation or object through to each of these sites, or adding a getOperationForObject() equivalent to each one. It also changes the meaning of operation_name in the property factory options, which third-party factories may rely on (BC concern).

For comparison, #8813 has the opposite trade-off:

resource_class → metadata object → resource
#8813 ✅ distinct per model ('DynamicFaq', 'DynamicNews'), so every lookup above works unchanged ❌ not implemented: isResourceClass(DynamicRecord::class) is false and getResourceClass() throws No resource class found for object of type "DynamicRecord"
#8840 ⚠️ one value for every model ✅ ResourceAwareInterface + getOperationForObject()

I think the two could be combined. Keep the resource name as resource_class (#8813), and use your ResourceAwareInterface where the core currently derives the resource from $object::class:

  • ResourceClassResolver::getResourceClass() / isResourceClass(): an object implementing it resolves to getResource()
  • AbstractItemNormalizer::supportsNormalization() / normalize()
  • the Symfony and Laravel IriConverter::getIriFromResource()

That would cover both columns without relying on shortName being unique per class or changing what operation_name means. The cost is the class_exists() guards, which are already in #8813 and come with tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants