Feat/gh7137 content range - #7856
Conversation
31e727b to
1606fe2
Compare
|
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
6b782e4 to
08565c7
Compare
|
I rebased this one on #8348 because it's a healthy foundation for HEAD features. |
92a2914 to
f079007
Compare
f079007 to
39293df
Compare
| !$request | ||
| || !$operation instanceof CollectionOperationInterface | ||
| || !$operation instanceof HttpOperation | ||
| || !\in_array($request->getMethod(), ['GET', 'HEAD'], true) |
There was a problem hiding this comment.
Blocking RFC issue: Range handling is defined only for GET. RFC 9110 §14.2 says a server MUST ignore Range for a method where handling is not defined, so HEAD must not be converted to a 206 request. This path also needs to account for If-Range and other preconditions before applying the range.
There was a problem hiding this comment.
Fixed in 89356d2. Range handling is now GET-only (§14.2): HEAD and any other method go through untouched (HEAD request and POST request cases in RangeHeaderProviderTest; the functional HEAD request case answers 200 with Accept-Ranges only).
If-Range is treated as a validator mismatch since a collection carries no validator to compare against, so the Range is ignored and the full collection served (§13.1.5). No other §13.1 precondition is evaluated on collections by API Platform.
Provider moved to the innermost position of the read chain (priority 120), so that a 403 or 422 always wins over 416: testAccessIsCheckedBeforeTheRange covers it.
| $options = $this->pagination->getOptions(); | ||
| $filters = $request->attributes->get('_api_filters', []); | ||
| $filters[$options['page_parameter_name']] = $page; | ||
| $filters[$options['items_per_page_parameter_name']] = $itemsPerPage; |
There was a problem hiding this comment.
Blocking functional issue: this filter is ignored by Pagination::getLimit() unless paginationClientItemsPerPage is enabled. With defaults, Range: books=10-19 therefore resolves to page 2 / offset 30 / limit 30, not offset 10 / limit 10. The successful tests all happen to request 30 items, which masks the problem. Range translation needs to set effective offset/limit independently of the client query-parameter permission.
There was a problem hiding this comment.
Confirmed and fixed in 89356d2, thanks for catching this!
The range is now translated into the page filter AND withPaginationItemsPerPage() on the operation, which is what Pagination::getLimit() reads regardless of the client permission. Range: items=10-19 on an operation with the default configuration resolves to offset 10 / limit 10 (testTranslatesTheRangeIntoAPageWhateverTheClientPaginationPermissions, plus the functional second page, ignoring the client items per page permission and shorter page cases, which no longer request the default page size).
| $filters[$options['items_per_page_parameter_name']] = $itemsPerPage; | ||
| $request->attributes->set('_api_filters', $filters); | ||
|
|
||
| $operation = $operation->withStatus(Response::HTTP_PARTIAL_CONTENT); |
There was a problem hiding this comment.
This unconditionally promises a partial representation before knowing what the provider returns. A custom provider returning an array, or an operation with pagination disabled, consequently produces 206 without Content-Range, which violates RFC 9110 §15.3.7.1. It also overwrites custom statuses even though Range is only evaluated when the response would otherwise be 200, and an offset beyond the collection currently becomes an empty 206 rather than 416.
There was a problem hiding this comment.
Fixed in 89356d2. The status is promoted to 206 only after the decorated provider returned, only when the result is a PartialPaginatorInterface, and only when the operation would otherwise answer 200 (a configured status is left alone, operation not responding with 200 case). A custom provider returning an array or an operation with pagination disabled is served as a plain 200 without Content-Range (testDoesNotPromiseAPartialContentWhenTheProviderDoesNotPaginate).
A range starting past the collection is now a 416 with Content-Range: <unit> */<total> when the paginator knows its total (testRejectsARangeBeyondTheCollectionWithItsCompleteLength, functional testARangeBeyondTheCollectionIsNotSatisfiable), and a bare 416 for a partial paginator, since */* is not a valid Content-Range (testRejectsARangeBeyondAPartialPaginatorWithoutCompleteLength).
| private static function extractRangeUnit(HttpOperation $operation): string | ||
| { | ||
| if ($uriTemplate = $operation->getUriTemplate()) { | ||
| $path = strtok($uriTemplate, '{'); |
There was a problem hiding this comment.
This derives the wrong unit for subresources. /companies/{companyId}/employees{._format} is truncated to /companies/, so employees=0-9 is ignored and the advertised unit becomes companies. More fundamentally, range-unit semantics and satisfiability rules need an explicit stable contract; dynamically deriving unregistered units from route text does not provide one.
There was a problem hiding this comment.
Agree and removed in 89356d2. The unit is now an explicit rangeUnit metadata exposed by GetCollection (PHP attribute, YAML and XML extractors, XSD), compared case-insensitively as §14.1 requires. Nothing is derived from the route any more, so /companies/{companyId}/employees simply declares rangeUnit: 'employees'.
The satisfiability rules are now explicit: only the bounded <first>-<last> form is accepted (open-ended and suffix ranges are ignored, pagination cannot serve them); the range must map onto a page, i.e. its first position is a multiple of its length and its length is within the maximum items per page; a range starting at or past the total is unsatisfiable.
| $headers['Accept-Ranges'] = self::extractRangeUnit($operation); | ||
|
|
||
| if ('HEAD' !== $method) { | ||
| $this->addContentRangeHeader($headers, $operation, $originalData); |
There was a problem hiding this comment.
Blocking RFC/BC issue: this adds Content-Range to every paginated collection response, including ordinary 200 responses with no Range request. RFC 9110 §14.4 defines meaning for this field only on 206 and 416. This is also an unconditional observable change for every existing paginated API. Accept-Ranges may advertise an enabled capability, but Content-Range should describe the actual 206/416 response.
There was a problem hiding this comment.
Fixed in 89356d2. Content-Range is only emitted on a 206 (testDescribesThePartialContent; testAFullResponseAdvertisesTheRangeUnitOnly asserts its absence on a 200). Accept-Ranges advertises the declared unit on the 200 and 206 responses of a collection operation that opted in, and nowhere else (testDoesNothingWithoutARangeUnit, testDoesNotAdvertiseTheRangeUnitOutsideOfASuccessfulResponse). The 416's Content-Range travels with the exception headers. No existing API gets a new header unless it declares a rangeUnit.
|
|
||
| $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); | ||
|
|
||
| $services->set('api_platform.state_provider.range_header', RangeHeaderProvider::class) |
There was a problem hiding this comment.
This enables the behavior globally. There is no framework setting or resource/operation metadata flag, so applications cannot opt in selectively and existing APIs gain new headers/Range semantics automatically. Given the provider and representation constraints, this should default to disabled and be enabled explicitly per operation (with an explicit range unit). Event-listener mode also does not wire this provider, while it still receives the response-trait changes.
There was a problem hiding this comment.
Fixed in 89356d2. The feature is opt-in per operation: without a rangeUnit the Range header is ignored and no Accept-Ranges is emitted (testARangeIsIgnoredWithoutARangeUnit, on a second fixture operation left unconfigured). There is deliberately no global framework setting, since the unit has to be chosen per collection anyway.
The provider is now wired in the event-listener mode as well (symfony/events.php), at the same innermost priority, and the functional test runs green under USE_SYMFONY_LISTENERS=1. It is also registered in the Laravel service provider, directly around ReadProvider, which matches the Symfony position; that part is not covered by a Laravel test yet.
1acead6 to
89356d2
Compare
|
Thanks for the review. All six points are addressed in 89356d2, on top of the two existing commits so that the delta since your review is easy to read:
Functional coverage in On the commit layout: if you would rather not squash on merge, I am happy to fold these fixes back into the two existing commits, which I think are a meaningful split on their own (the response side, One decision I would like to make with you rather than alone. When a range is well-formed but cannot be served as a page (not aligned on a page boundary, or wider than the maximum items per page), the PR currently answers 416 with an explanatory Related: which branch should this target, with 5.0 approaching? The feature is opt-in, so nothing changes for an API that does not declare a |
Pagination in headers: Range request header and content-range response handling.
I created a PR for this topic, please see the issue for the 'why it's cool'.
For now, there is 2 separate commits:
Range request header
This commit adds server-side parsing of the Range request header, enabling clients to request specific slices of a collection via standard HTTP semantics.
RFC 9110 sections followed
§14.2 Range — defines the request header format:
Range: <unit>=<first-pos>-<last-pos>. Unrecognized formats or units are silently ignored (the server delegates normally), as recommended by the spec.§15.3.7 206 Partial Content — the response status when a valid Range request is successfully fulfilled. The operation status is set to
206soRespondProcessorreturns it alongside theContent-Rangeheader.§15.5.17 416 Range Not Satisfiable — returned when the range is syntactically valid but not satisfiable (start > end, or range not aligned to page boundaries).
Summed up design considerations
Main logic implemented
RangeHeaderProviderdecorator on the read provider — parses theRangeheader, converts the range to page/itemsPerPage pagination filters via_api_filters, and sets the operation status to206. Follows the same decorator pattern asJsonApiProvider.provider.php— decoratesapi_platform.state_provider.read(at priority 1).Tests
RangeHeaderProviderTest— 9 unit tests covering delegation (no header, wrong operation type, wrong HTTP method), ignored formats (unparseable, wrong unit), valid ranges, and416errors.ContentRangeHeaderTest— verify the full flow: Range header in →206status +Content-Rangeheader out.Content-range response header
This commit adds RFC 9110-compliant Content-Range and Accept-Ranges response headers to all paginated collection endpoints.
RFC 9110 sections followed
§14.4 Content-Range — defines the header grammar:
range-unit SP (range-resp/unsatisfied-range). We use custom range units (for examplebook) as allowed by the extensible range unit mechanism. The unsatisfied-range production (*/complete-length) requirescomplete-lengthto be a digit, so the header is omitted when both the page is empty and the total is unknown (since*/*would be invalid ABNF).§14.3 Accept-Ranges — advertises which range unit the server supports for a given resource. Sent on every paginated collection response.
§14.1 Range Units — confirms that range units are extensible tokens, not limited to
bytes.§15.3.7 206 Partial Content — clarifies that
206is strictly reserved for responses toRangerequests. Since this commit only handles the response side (noRangerequest parsing yet), the status code remains200.Summed up design considerations
Main logic implemented
HttpResponseHeadersTrait::addContentRangeHeaders()— fetches range from paginator (0-indexed offsets) and emits theContent-Range&Accept-Rangesheaders.Tests
ContentRangeHeaderTest— 11 test cases covering partial collections, full collections, page offsets, unknown totals, empty pages, non-collection operations, and the unit fallback.Limitations & concerns
HEADverb, to just receive pagination through header'sContent-range, doesn't save any cycles as it is processed just like aGET. The content is stripped right before sending the response. There's a clear room for improvement here! → Will be in a totally separated issue, because it's another topic.200responses but API-Platform fires some206),ContentRangeHeaderTest&RangeHeaderProviderTestto help designing the feature. We can totally merge everything in other testfiles if wanted.feat(state): ...→ is it really the best domain? Don't we have a headers domain?Content-Range&Rangetopics can totally be in 2 separate PR, if you want to zoom in for reviews & discussions. Let me know.Content-Range. I put it in plural because it was easy in current implementation.