Catch OutOfRangeException for invalid component versions
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3583746. -->
Reported by: [mglaman](https://www.drupal.org/user/2416470)
Related to !884
>>>
<h3 id="overview">Overview</h3>
<p>An invalid component_version value in the request body causes an unhandled OutOfRangeException to bubble up as HTTP 500. The API should return a 400/422 with a validation error.</p>
<p>An AI agent exploring the Canvas Pages API submitted a POST to /canvas/api/v0/content/canvas_page with a malformed component_version field. The agent concatenated two valid version hashes (1d25f132d5e34061 and cd7a420ca5381045) into a single string (1d25f132d5e34061', 'cd7a420ca5381045) instead of choosing one.</p>
<pre>Uncaught PHP Exception OutOfRangeException: "The requested version `1d25f132d5e34061', 'cd7a420ca5381045` is not available. Available versions: `1d25f132d5e34061`, `cd7a420ca5381045`."<br>at VersionedConfigEntityBase->assertVersionExists() (line 73 of VersionedConfigEntityBase.php)</pre><p>ComponentTreeStructureConstraintValidator::validateComponentInstance() (line 296 of src/Plugin/Validation/Constraint/ComponentTreeStructureConstraintValidator.php) calls:</p>
<pre>$parent_config_entity->loadVersion($parent_instance['component_version']);</pre><p>loadVersion() calls assertVersionExists() which throws \OutOfRangeException for any unrecognized version string. This call has no guard.</p>
<p>The Sequentially constraint does prevent the callback from running for the component with the bad version itself — but for any child component that references the bad-version component as its parent, validateComponentInstance still runs and hits this loadVersion call with the invalid string.</p>
<p>The ApiExceptionSubscriber maps OutOfRangeException to HTTP 500 (the default catch-all) since it only special-cases HttpExceptionInterface and ConstraintViolationException.</p>
<p>The same HTTP 500 can be triggered through JSON:API write operations (PATCH/POST). ComponentTreeStructureConstraintValidator is a shared constraint on the component_tree field type — it runs whenever any entity with that field is validated, regardless of write path.</p>
<p>Three entity types expose this field via JSON:API (confirmed by JsonapiSupportTest):</p>
<ul>
<li>canvas_page (content entity)</li>
<li>ContentTemplate (config entity)</li>
<li>PageRegion (config entity)</li>
</ul>
<p>A PATCH or POST to any of these via JSON:API with a child component referencing a bad-version parent will hit the same unguarded loadVersion() call at ComponentTreeStructureConstraintValidator.php:296. Drupal's DefaultExceptionSubscriber only special-cases HttpException types, so OutOfRangeException falls through as a 500 there too.</p>
<p>The proposed fix (wrapping loadVersion in a try/catch in validateComponentInstance()) covers all write paths since it's in the shared constraint validator. The acceptance criteria should include a test via JSON:API as well.</p>
<p>On the hydration/render path (ComponentTreeItemList::getHydratedValue() also calls loadVersion() without a guard) — throwing there is acceptable. If bad data somehow reached storage, surfacing an exception during rendering is appropriate behavior. The focus here is on the validation boundary: preventing external clients from pushing in invalid data and getting a 500 back.</p>
<h3 id="proposed-resolution">Proposed resolution</h3>
<p>Wrap the loadVersion call in validateComponentInstance() with a try/catch for \OutOfRangeException and add a constraint violation instead of letting the exception escape:</p>
<pre>// src/Plugin/Validation/Constraint/ComponentTreeStructureConstraintValidator.php:296<br>try {<br> $parent_config_entity->loadVersion($parent_instance['component_version']);<br>} catch (\OutOfRangeException) {<br> $context->buildViolation(<br> 'Invalid component tree item with UUID %uuid references parent %parent_uuid with unavailable version %version.',<br> [<br> '%uuid' => $component_instance['uuid'],<br> '%parent_uuid' => $component_instance['parent_uuid'],<br> '%version' => $parent_instance['component_version'],<br> ]<br> )<br> ->atPath('parent_uuid')<br> ->addViolation();<br> return;<br>}</pre><p>This converts the 500 to a 422 via the existing createJsonResponseFromViolationSets path in ApiContentControllers::post().</p>
<h3 id="ui-changes">User interface changes</h3>
issue