Problem/Motivation

#3613823: Replace Accept-Language read negotiation with URL-keyed language selection (langCode + includeFallback) made reads URL-keyed: the langCode query parameter (optionally with includeFallback=1) is the only read-side language selector, and language request headers are inert on reads. Writes, however, still accept two selectors: the same langCode query parameter and the Content-Language request header, documented as "functionally identical and interchangeable".

The second selector earns nothing and costs real surface:

  • a mismatch-validation branch in getResourceLanguage() for the case where both selectors are present and disagree (its own 422, its own test matrix);
  • two Content-Language branches in EntityAccessChecker::getEntityTranslation() (content and config), reachable only through writes with ?include=;
  • a header parameter on every write operation in the generated OpenAPI document;
  • the "headers are inert on cacheable requests" guard commentary that exists only to fence the header off from the read path;
  • a residual header dependency in a contract whose whole point, since #3613823: Replace Accept-Language read negotiation with URL-keyed language selection (langCode + includeFallback), is that language state lives entirely in the URL.

Every documented client path already works with langCode alone, and the planned api_client adapter sends langCode on writes and never sets Content-Language.

Steps to reproduce

Not a bug; current behavior is as designed. PATCH /jsonapi/node/article/{uuid} with Content-Language: fr and no langCode currently patches the fr translation; after this change it is rejected (see below).

Proposed resolution

Retire the Content-Language request header as a language selector. langCode becomes the single selector for every operation, reads and writes alike.

  • A write carrying the retired header is rejected with 400 Bad Request ("The "Content-Language" request header is not supported. Use the "langCode" query parameter to select the language."), not silently ignored. Silence is the dangerous option: a legacy client's PATCH with only the header would land on the default translation and overwrite it with the other language's values, and a header-only DELETE would have no language target at all and delete the whole entity. The rejection fires even when the header agrees with a valid langCode, so the retirement is unambiguous. Writes are never cacheable, so the header-dependent 400 has none of the cache-poisoning exposure that drove the read-side inertness rule (#3613686).
  • Reads are unchanged: language request headers stay inert, Content-Language exactly like Accept-Language, for the URL-keyed cache-safety reasons established in #3613823.
  • The Content-Language response header is untouched. Responses keep labeling themselves with the language actually served.
  • Deleted: the header read and the langCode-vs-header mismatch 422 in getResourceLanguage(); both header branches in EntityAccessChecker::getEntityTranslation(); the write-side header parameter in the OpenAPI generator. The payload-vs-metadata rule is unchanged: a payload langcode attribute, if present, must still match the targeted language.
  • Companion simplification, enabled by the same single-selector contract: the jsonapi_menu_items request attribute becomes a scalar (ATTR_MENU_ITEMS_LANGCODE) instead of the Accept-Language-era "ordered list of one", removing the array guards and candidate loop from RequestLanguageEntityRepository, MenuItemsLanguagePathProcessor and the response-labeling listener; and getCollectionQuery() stops re-declaring the language cache contexts that addLanguageCacheability() already declares on every translatable collection response.
  • Divergence tracking: core #3199697 accepts Content-Language on writes, so this is the module's third tracked DIVERGENCE(core): departure (after the ignored Accept-Language read header and the additive includeFallback parameter). The marker carries the usual propose-upstream-or-restore plan; the upstream argument is the same single-selector one: one selector to validate, the whole contract in the URL.
  • Specs (translation-endpoints, core-alignment, menu-translation, openapi-integration), README, CONTRIBUTING examples and hook_help are updated to the single-selector contract.

API changes

Breaking for any client that selects a write's language with the Content-Language request header: such writes now return 400 Bad Request with migration guidance in the error detail. The two selectors were documented as interchangeable, so the migration is mechanical: send ?langCode=<langcode> instead. Clients already using langCode, and all reads, are unchanged. The generated OpenAPI document no longer lists a Content-Language parameter on write operations. The Content-Language response header is unchanged.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

lauriii created an issue. See original summary.

  • lauriii committed 952b4e3c on 1.0.x
    task: #3614200 Retire the Content-Language request header as a write-...
lauriii’s picture

Status: Active » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.