Skip to content

Feat/gh7137 content range - #7856

Open
Nayte91 wants to merge 3 commits into
api-platform:mainfrom
Nayte91:feat/GH7137-content-range
Open

Feat/gh7137 content range#7856
Nayte91 wants to merge 3 commits into
api-platform:mainfrom
Nayte91:feat/GH7137-content-range

Conversation

@Nayte91

@Nayte91 Nayte91 commented Mar 19, 2026

Copy link
Copy Markdown
Contributor
Q A
Branch? main
Tickets #7137
License MIT
Doc PR no but I can do this after

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:

  • 1 for content-range response
  • 1 for range request

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 206 so RespondProcessor returns it alongside the Content-Range header.
§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

  • where does API-Platform give access to request's headers?
  • where do we give pagination's info to ORMs?
  • where do we put code that link those?

Main logic implemented

  • A new RangeHeaderProvider decorator on the read provider — parses the Range header, converts the range to page/itemsPerPage pagination filters via _api_filters, and sets the operation status to 206. Follows the same decorator pattern as JsonApiProvider.
  • Service registration in provider.php — decorates api_platform.state_provider.read (at priority 1).

Tests

  • A dedicated RangeHeaderProviderTest — 9 unit tests covering delegation (no header, wrong operation type, wrong HTTP method), ignored formats (unparseable, wrong unit), valid ranges, and 416 errors.
  • Two additional integration tests in ContentRangeHeaderTest — verify the full flow: Range header in → 206 status + Content-Range header 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 example book) as allowed by the extensible range unit mechanism. The unsatisfied-range production (*/complete-length) requires complete-length to 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 206 is strictly reserved for responses to Range requests. Since this commit only handles the response side (no Range request parsing yet), the status code remains 200.

Summed up design considerations

  • where does API-Platform give access to ORMs' pagination info?
  • where do we build response's headers before sending it?
  • where do we code the link between those?

Main logic implemented

  • Private method HttpResponseHeadersTrait::addContentRangeHeaders() — fetches range from paginator (0-indexed offsets) and emits the Content-Range & Accept-Ranges headers.

Tests

  • Dedicated ContentRangeHeaderTest — 11 test cases covering partial collections, full collections, page offsets, unknown totals, empty pages, non-collection operations, and the unit fallback.

Limitations & concerns

  1. Current use of a HEAD verb, to just receive pagination through header's Content-range, doesn't save any cycles as it is processed just like a GET. 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.
  2. Potential BC break for projects that use and make proper implementation of those headers
  3. API break if status codes are willing to change (if your project is wired to make things on 200 responses but API-Platform fires some 206),
  4. In committed files there's a lot of comments with RFC links, to help reviewing. It's meant to be removed.
  5. I did a specific testfiles, ContentRangeHeaderTest & RangeHeaderProviderTest to help designing the feature. We can totally merge everything in other testfiles if wanted.
  6. feat(state): ... → is it really the best domain? Don't we have a headers domain?
  7. Content-Range & Range topics can totally be in 2 separate PR, if you want to zoom in for reviews & discussions. Let me know.
  8. We need to discuss also about resource in plural in Content-Range. I put it in plural because it was easy in current implementation.

@Nayte91
Nayte91 force-pushed the feat/GH7137-content-range branch 4 times, most recently from 31e727b to 1606fe2 Compare March 21, 2026 09:48
@stale

stale Bot commented May 20, 2026

Copy link
Copy Markdown

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.

@stale stale Bot added the stale label May 20, 2026
@stale stale Bot closed this May 27, 2026
@soyuka soyuka reopened this Jun 23, 2026
@soyuka soyuka added http HTTP layer: Response/HttpCache/content-negotiation and removed stale labels Jun 23, 2026
@Nayte91
Nayte91 force-pushed the feat/GH7137-content-range branch 2 times, most recently from 6b782e4 to 08565c7 Compare June 23, 2026 13:59
@Nayte91

Nayte91 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

I rebased this one on #8348 because it's a healthy foundation for HEAD features.

@Nayte91
Nayte91 force-pushed the feat/GH7137-content-range branch 3 times, most recently from 92a2914 to f079007 Compare July 16, 2026 12:08
@Nayte91
Nayte91 force-pushed the feat/GH7137-content-range branch from f079007 to 39293df Compare September 12, 2026 12:36
!$request
|| !$operation instanceof CollectionOperationInterface
|| !$operation instanceof HttpOperation
|| !\in_array($request->getMethod(), ['GET', 'HEAD'], true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, '{');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Nayte91
Nayte91 force-pushed the feat/GH7137-content-range branch from 1acead6 to 89356d2 Compare September 13, 2026 09:25
@Nayte91

Nayte91 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • opt-in rangeUnit metadata,
  • GET-only range handling with If-Range treated as a mismatch,
  • the 206 promised only once a paginator came back,
  • 416 with Content-Range: unit */total past the collection, Content-Range restricted to 206 responses,
  • the provider wired innermost in both the default and the listener modes plus Laravel.

Functional coverage in tests/Functional/RangeRequestTest.php.

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, Accept-Ranges and Content-Range, then the request side, Range to 206). Just say which you prefer.

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 detail, so that the client learns the constraint instead of silently receiving a full page. Strictly speaking, RFC 9110 §14.2 lets a server ignore a Range it cannot honour, and §15.5.17 defines 416 for ranges that are unsatisfiable against the representation, which is not exactly this case. The alternatives are: keep the loud 416 as it stands, or ignore such ranges and serve the regular 200 with Accept-Ranges only. I lean towards the loud variant for API clients, but it is a behaviour that will be hard to change once released, so I would rather have your call before it lands.

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 rangeUnit, but once it ships, the 416 above becomes a contract for those who do, and there is no 416 anywhere in API Platform today. Three options as I see them: main only, so it ships with 5.0 and the 416-versus-ignore choice can still be adjusted during the alpha cycle without a BC concern; 4.4 as well, with the PR retargeted and merged up, if you consider an opt-in feature acceptable in a maintenance branch; or later, if you would rather see 5.0 out first. I would avoid shipping different behaviours for the same option in 4.4 and 5.0. Happy to retarget or rebase accordingly.

@Nayte91
Nayte91 requested a review from soyuka September 13, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

http HTTP layer: Response/HttpCache/content-negotiation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants