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-&gt;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-&gt;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 &mdash; 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 &mdash; 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) &mdash; 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>&nbsp;&nbsp;&nbsp; $parent_config_entity-&gt;loadVersion($parent_instance['component_version']);<br>} catch (\OutOfRangeException) {<br>&nbsp;&nbsp;&nbsp; $context-&gt;buildViolation(<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 'Invalid component tree item with UUID %uuid references parent %parent_uuid with unavailable version %version.',<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; [<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '%uuid' =&gt; $component_instance['uuid'],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '%parent_uuid' =&gt; $component_instance['parent_uuid'],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; '%version' =&gt; $parent_instance['component_version'],<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ]<br>&nbsp;&nbsp;&nbsp; )<br>&nbsp;&nbsp;&nbsp; -&gt;atPath('parent_uuid')<br>&nbsp;&nbsp;&nbsp; -&gt;addViolation();<br>&nbsp;&nbsp;&nbsp; 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