Problem/Motivation
JSON:API does not fully support translatable content entities, for instance https://github.com/jsdrupal/drupal-admin-ui will need this.
Regarding GET support, JSON:API already has implicit translation support, that is /fr/jsonapi/node/article/UUID will return a JSON:API response containing the French translation of the given article. The problem with this is that it depends on and varies by each site's language negotiation configuration. This means translations may behave differently on Drupal-powered JSON:API instances A versus B. This is suboptimal because:
- it violates the assumption that one JSON:API instance works the same as another
- it makes it hard (potentially impossible) to write client libraries that can be reused across projects by the community
- it makes it impossible to standardize in a JSON:API profile (coming in the upcoming 1.1 version of the JSON:API specification).
- it results in automagic fallback behavior in edge cases that may be fine for HTML use cases but not for JSON:API use cases (see @corbacho in #69 for a concrete example)
CUD operations on translations have little to no support, at the moment (see #80-#89 and #3038308: Avoid translations DELETE data loss and unintended changes with PATCH and test all methods against entity route parameter translation upcasting).
Proposed resolution
Define a single, non-configurable, well-defined language negotiation mechanism. That defines JSON:API's responses in the edge cases of requesting a non-existing translation and a non-existing langcode. Implement full CUD translation support on top of that.
The following specification covers individual operations (see #100-#109 for the related discussion):
https://github.com/gabesullice/drupal-jsonapi-translations/blob/master/i...
This issue cannot remove the current automatic behavior (that'd be too disruptive), but users would have to update to the formal/official mechanism if they want the edge cases to be handled in a consistent, sensible manner. Instead this will introduce a new experimental module (JSON:API Translation), implementing all the new/missing logic. This would allow people to opt-in without requiring any additional configuration. The goal would be to deprecate the legacy (GET) logic and merge the module into JSON:API in D10.
Remaining tasks
How collections should behave is still being discussed (see #69 and #110).
- #3199697: Add JSON:API Translation experimental module
- #3199698: [PP-1] Add translation support to JSON:API collections
Done
- #3199696: Add language support to ResourceObject commited in 9.4.x and 10.0.x
| Comment | File | Size | Author |
|---|---|---|---|
| #126 | 2794431-jsonapi_translation-126.patch | 57.12 KB | plach |
| #126 | 2794431-jsonapi_translation-126.interdiff.txt | 2.57 KB | plach |
| #121 | 2794431-jsonapi_translation-121.patch | 54.32 KB | plach |
| #121 | 2794431-jsonapi_translation-121.interdiff.txt | 7.48 KB | plach |
| #118 | 2794431-jsonapi_translation-118.interdiff.txt | 1.4 KB | plach |
Comments
Comment #2
gabesulliceI know this is opening a can of worms, but it's a current business need. Attached patch is by no means complete, hopefully it will help get the ball rolling though.
Edit: I should note that this actually does work as expected and without errors.
Comment #3
e0ipsoAdded related REST core issue.
Comment #4
janlaureys commentedNot sure if this is still relevant. But I took #2 and modified two lines so that it's possible to use the Accept-Language header.
When the header isn't set acceptLanguage will be null but the $langcode parameter of getTranslationFromContext is optional so it works out.
/me is horrible at naming variables
Comment #5
heldercor commentedActually without this I can't use jsonapi. All I have is multilingual sites.
Comment #6
heldercor commentedRe-patching #2 against HEAD.
Comment #7
heldercor commentedJust got a TypeError exception.
Comment #8
e0ipsoThis looks good. It needs a test though.
Comment #9
e0ipsoComment #10
dawehnerI believe the current patch does the right thing, as in, just follow what core provides us as a language, see #2135829: EntityResource: translations support for a discussion how to determine this language.
I'm working on some test coverage here.
Comment #11
dawehnerHere is a test
Comment #13
dawehnerThis failure really doesn't make any sense :( So yeah this is certainly needs work.
Comment #14
hampercm commented@dawehner The tests are failing because they instantiate a JsonApiDocumentTopLevelNormalizer. Since that class's constructor has changed to additionally pass in an EntityRepositoryInterface, this parameter will need to be added in the tests as well.
Comment #15
dawehner@hampercm
Good point, well, what I don't get it why the -fail patch, with just the test changes includes, doesn't fail.
Comment #16
e0ipsoWe are not providing mock data in the
providerTestRead. We should find a less confusing technique to test the multilingual capabilities.Additionally, most of these tests are testing business logic that is independent from multilingual stuff. I'm not sure about the value of duplicating the execution time for this. Maybe a multilingual specific test + Kernel test for the JsonApiDocumentTopLevelNormalizer would be more appropriate.
Comment #17
e0ipsoCan it be that the
EntityStorageBase::loadMultipleloads the entity for the appropriate language already? We are using that method to load entities for individual items and for collections.Comment #18
e0ipsoFinally (and I'm sorry to provide this feedback now, since this code has been there 5 months already):
If we are to override the
$entityobject with a translation, we should do it right after we load the entity. In any case we need to make sure that we need to do this, since @dawehner's tests indicate that we do not. That is:1. In the ParamConverter.
2. In the Controller for collections.
Comment #19
dawehnerSo right, this patch enables translations on collection level. It works already on the individual level.
Test coverage is a tricky topic. I started indeed with a dedicated method, but then thought we maybe want to follow the route of #2737719: EntityResource: Provide comprehensive test coverage: for every entity type, every format, every method which basically went the full multi-dimensional test coverage approach instead of the monte-carlo based testing approach, which tries to coverage everything but just choosing educated random points in the entire testing space.
For now I think keeping things simple, aka. a dedicated test coverage with a good name, is the better idea.
Comment #21
dawehner... So this time for real
Comment #22
hampercm commented1. getEntityAndAccess() is called in multiple places. Would it make more sense to get the translated entity inside that method?
Comment #23
dawehnerGood point. I totally think it makes sense.
Comment #24
dawehnerI expanded the test coverage and figured out we have another place where we missed loading the right entity.
Comment #25
hampercm commentedOverall, the patch from #24 looks good. Some comments:
Make the $entity_repository a static member so we can use DI, instead?
Good idea! There are other functional tests where this could be reused, too (note to self)
Moving the multilingual tests to a separate class might make sense. A benefit of this would be that the functional tests (which are typically the longest-running) could run in parallel
Comment #26
e0ipsoThanks @dawehner and @hampercm. I'm gonna finish this one off!
FYI, I'm going to change
'es'to'ca'. For non-technical reasons ;-)Comment #27
e0ipsoI will skip this for now, since there is no unit test in that class yet. With kernel tests we can replace the service and be done. Is that ok?
Comment #28
e0ipsoI run this command to compare the speed to run the tests:
Before splitting: 46.33s
After splitting: 47.91s
So I don't think there are significant differences. However, I believe that the code is a bit cleaner after splitting.
I'll be merging if tests come back green.
Comment #29
dawehnerSure why not
Comment #30
e0ipsoComment #32
e0ipsoCommit
iswas not showing up. This was committed in http://drupalcode.org/jsonapi/commit/?id=905e5e0Comment #33
wim leersI'm a bit concerned, because IIRC there was no consensus yet at #2135829: EntityResource: translations support on how to support requesting translation. Quoting #2135829-22: EntityResource: translations support:
Yet that is what this patch introduced! I don't see this discussed here at all. We need to be very careful here.
Also, a review of the last patch:
Looks good!
Woah this is tricky!
This can be massively simplified, see
\Drupal\Tests\rest\Functional\ResourceTestBase::request.Comment #34
wim leersComment #35
e0ipsoAs I see it, we should not leave the code as is in the current state since there were translation inconsistencies where some things were translated and some not. Meaning that the user was still able to hit the language variant URL (
/ca/jsonapi/node/article) before this patch and get some mixed results.This commit/issue sets some honesty to that limitation. I agree that the URL negotiation is REST impure, but with this code we could have support for any language negotiation. Someone may add a contrib that negotiates language based on
Accept-Languageheader, and with this code we support that.I'm going to be the unpopular guy and say that we're not being RESTful purists to start with. Core does not achieve the RESTful holy grail either. The main difference is that core tries to. We are playing the practicality tune here, and I'd rather have this fixed in an imperfect way than wait on consensus in a 14 Nov 2013 issue. When that happens we need to see if it makes sense for us. Core outputs 1 entity, we output many, someone may argue for a different language for each (?) …
That being said, let's keep this as Active to show contributors that we are still open to adopt a more REST compliant resolution.
Comment #36
wim leersThat's fair. But we could have changed it to never do any translation.
That's also fair. But it should've been discussed prior to commit. And now that it is committed, we should explicitly state this in the docs (i.e. in
README.md).Once it is documented, I think it's fine to close this.
Comment #37
dawehnerWell to be honest if you care about this restfull ness you can configure Drupal to take into account something else. The language negotation system is really flexible, so you could configure your site to be more pure.
Note: We've learned that the pure REST stuff doesn't serve us really well anyway, just look at HAL and its complexity.
Comment #38
e0ipso@Wim is this something you want to take on?
Maybe we can create a separate issue for it.
Comment #39
e0ipsoWe still need docs on this.
Comment #40
wim leersMarked #2897230: [PP-1] Creating translations (via PATCHing entities?) as a duplicate of this
Comment #41
wim leersLet's just postpone this on #2135829: EntityResource: translations support.
Comment #42
wim leersComment #43
gabesulliceLet's not postpone this on support in REST module, let's just postpone it on the underlying issue: #2904423: Translated field denormalization creates duplicate values, which looks like it got stalled, but could use some new life.
I'm not even convinced that that issue block us, since we don't use the same normalizations as HAL+JSON. Maybe @Wim Leers could do a little digging?
Thinking about CUD in CRUD, I think we may want to transition from a URL-path based approach to language negotiation to a query parameter based method. E.g.
/jsonapi/node/article/{uuid}?lang_code=en-usWhy?
Comment #44
wim leersWe don't even need to block this on #2904423: Translated field denormalization creates duplicate values then, because that issue is about fixing a bug in the
halmodule's normalizers. Which don't affect JSON API: JSON API only reuses some of theserializationmodule's normalizers.You were exactly right!
Comment #45
wim leersI omitted the
[FEATURE]prefix … but it's marked as a task. Let's mark it as a feature request.Comment #46
wim leers@e0ipso, do you have feedback on @gabesullice's proposal in #43?
Comment #47
wim leersI started working on this!
Comment #48
wim leersThe initial patch was committed in #31.
Let's start by expanding the test coverage for what is working today.
Comment #49
wim leersAdds test coverage for all hitherto untested routes, to verify that translations are in fact respected there too!
Comment #50
wim leersWith #49, we have at least basic test coverage for each of the routes (plus
includes). This allowed me to attempt to remove either of the threegetTranslationFromContext()calls that were added to JSON API in #31.I was able to remove only 1 out of 3: the one in
\Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::normalize()! This makes sense, because by the normalizer is given an existing entity object, and that should already be respecting translations. (This also happens to be the one I criticized in #33.2.)The remaining two are in:
\Drupal\jsonapi\Controller\EntityResource::getAccessCheckedEntity()(necessary for loading the relevant translation after performing an entity query to get a particular translation)\Drupal\jsonapi\ParamConverter\EntityUuidConverter(necessary when the entity object is a route parameter passed to the controller directly, for example theindividualroute)I think I should be able to remove the latter too.
Comment #51
wim leersI was able to remove the
getTranslationFromContext()call fromEntityUuidConverterif it weren't for the test coverage that I added in #49. Already the test coverage is proving useful… 🙏Having only
getAccessCheckedEntity()callgetTranslationFromContext()is not enough because\Drupal\jsonapi\Controller\EntityResource::getRelationship()does not callgetAccessCheckedEntity().So for now, we'll stick with the two remaining
getTranslationFromContext()calls.Comment #52
wim leersNext: trying to make #43 happen. @gabesullice made good points about why header-based negotiation is not a good fit, and why this is a place where core's pluggability/extensibility wrt language negotiation makes things harder: because we can't guarantee a certain DX.
This is what that would look like.
And that actually allowed us to remove
EntityUuidConverter'sgetTranslationFromContext()too! Now there's only onegetTranslationFromContext()left!Before we go further in this direction, this needs to get reviews from @gabesullice and @e0ipso.
Comment #53
wim leersFurther questions:
POST,PATCHandDELETEfor now?EDIT: quoting @e0ipso in #2987205-22: FormatSetter doesn't set the format to `api_json` when accessing just `/jsonapi`:
That suggests the answer to my first question is "it's not worth doing right now".
Comment #55
wim leersThis should fix most failures.
Comment #57
wim leersQuestions still to be answered:
POST/PATCH/DELETE.linksentries pointing to other translations?Comment #58
grimreaperHello,
Here is a rebased version of the patch from comment 55.
I can't get an interdiff.
I need to test it.
Also what should be done for POST / PATCH / DELETE?
I need it for a multilingual project.
Comment #59
wim leersWhat do you propose? How do you think it should work?
Comment #60
grimreaperHow I think it should work.
Create a new content in the specified langcode (or current langcode if not specified): POST jsonapi/node/article?lang_code=LANGCODE
Create a new translation/Update an existing translation (lang_code parameter mandatory): PATCH jsonapi/node/article/UUID?lang_code=LANGCODE
Delete a translation: DELETE jsonapi/node/article/UUID?lang_code=LANGCODE
Delete the whole entity: jsonapi/node/article/UUID
Use the native behavior to display the entity in another language depending on languages weight.
For deletion of a non-existing translation, returns an explicit error message.
Use the native behavior to display the entity in another language depending on languages weight.
If it PATCH / POST / DELETE operation on a non installed langcode, returns an explicit error message.
Comment #61
gabesulliceSince #43, I've become much more familiar with RFC8288 and web linking. It defines an "hreflang" link parameter for links. Which implies that a single URL href would/could serve multiple languages. This is correct. Because GET, POST, PATCH and DELETE can use the
Accept-LanguageandContent-Languageheaders on request and responses.I would like to argue against myself in #43 then and say this should be primarily header based.
Why?
Because if we want to use links to navigate languages, we can either have:
and have clients use
Accept-Languageto request the various languages... or, we can have the much uglier query parameter-based negotiation:Yuck.
So, why not header-based negotiation?
Because some CDNs do not have very good support for varying their caches by
Accept-Languageand naively varying byAccept-Languagecan destroy cache performance (there are ways to do it right though).That said, I don't think that's reason enough to abandon header-based negotiation.
The reason
Accept-Languagecan destroy cache performance is because browsers produce wildly different values for that header.However, JSON API doesn't need to be very concerned about that because JSON API requests are not typically made directly via a browser. Instead, they're most frequently made via JavaScript or some other system where the
Accept-Languageheader can be tightly controlled by a developer.There's one caveat. Some CDNs simply don't support cache variation by
Accept-Languageat all.In that case, I'll propose a compromise. A container parameter which enables negotiation via a query parameter. This should be off by default. In that way, we won't leave anyone completely stuck, but we'll be doing things "right" by default.
Comment #62
gabesulliceActually, the "compromise" I suggested in #61 could be a way to keep the current path-based negotiation in place without breaking BC. If it's not on by default, I'm ambivalent about whether we use a query parameter or a path prefix to do language negotiation in the URL.
Comment #63
e0ipso@gabesullice we should probably use the first applicable active language negotiator on the site. Applicable in the sense of being a URL based negotiator, but I'm unsure how to detect that a negotiator is URL based.
Comment #64
wim leersActually, like I've said since the very beginning: I am concerned about using Drupal's language negotiation system. That system is intended for web sites, not for HTTP APIs. For a HTTP API, we IMHO want it to be documentable, and not for it to have dozens of possible permutations of how it might work depending on how some module is configured.
Am I the only one who feels this way?
Comment #65
gabesulliceI think I agree with @Wim Leers here. We want our solution to be documentable, deterministic and API-First.
@e0ipso, maybe @Wim Leers and I are overlooking something? You seem confident in your proposal. But I don't see what problems doing what you say would avoid or what it makes possible. IOW, what would we lose by not using language negotiators?
Here's my reasoning for not using them:
AFAICT, the language negotiation stuff is pretty much just a menu of workarounds for browsers being bad at
Accept-Language. And that's just another way of saying "the system is intended for web sites".Since we don't have the same constraints to contend with in an API-First/decoupled environment, I think we can ignore the language negotiators (or have our own and only use that one).
What we gain is an easily documentable solution that doesn't change from application to application and that can make use HTTP and web linking as it's meant to be used (which buys us robustness and compatibility).
Comment #66
wim leersI disagree with this.
The language negotiation system used on a specific website depends on the locale: in Belgium it's common to see
company.be/nlandcompany.be/fr. I've got the impression that in the primarily Anglo-Saxon countries (U.K., U.S., Australia, Canada, and others), it's based on TLD:company.co.uk,company.com.aucompany.caandcompany.com.This makes sense for those cases where the URL is the interface used by entire non-technical peoples. Because in that case, it's essentially joint conventions — much like hyperlinks are always underlined.
But in the context of HTTP APIs, the user is not a non-technical one, but a developer. And hence in this case, we need less flexibility and more predictability.
Comment #67
gabesullice@Wim Leers, I'm not convinced, but it'd just be noise to continue debating about why we've come to the same conclusion :P
Comment #68
wim leersHeh, fair :)
@e0ipso, can you explain your rationale for why you think using Drupal's existing language negotiation system does not cause DX and maintainability problems & risks?
Comment #69
corbacho commentedSorry to hijack the issue, to expose a practical real problem related to the discussion:
We are using JSON:API to fetch content for a Gatsby multilingual site.
We are relying currently on the "implicit entity translation magic" that core does. So we have 2 endpoints in Gatsby config: one for Finnish
/fi/jsonapi, one for English content/en/jsonapi.Gatsby recursively will grab every single entity exposed via JSON:API by default, in 2 iterations. The order is important, as you will see:
1st) in Finnish
/fi/jsonapi2nd) in English
/en/jsonapiIt collects all the data into a single big JS variable, indexed by "language-id" (if it would be indexed only by id, it would get overwritten)
Setup explained here https://github.com/gatsbyjs/gatsby/issues/10020
This setup works perfectly if all the articles are translated. But if an article is only provided in Finnish, then the second iteration Gatsby does (fetching articles in English), will ALSO get the Finnish version (because of the un-wanted language fallback). This will result in duplicated content in Gatsby, by indexing two nodes with the same id:
fi-98303 (Finnish article)
en-98303 (Finnish article)
To fix this problem we tried a simple solution: append
?filter[langcode][value]=fifor every request that Gatsby does, but it didn't work because:1) There are many entities that will throw error
Invalid nested filtering. The field 'langcode', given in the path 'langcode, does not exist2) Paragraph entities are not filterable by langcode (even if they have the attribute). They just return an empty array. (no error)
So our current solution is a hack to detect in Gatsby Drupal plugin code if it's fetching URLs that contain the string
/node/. Then, we add the langcode filter. This works well for nodes, and taxonomy terms so far.It would be good if JSON:API module would offer a simpler way to avoid fallback translations.
Comment #70
wim leersThanks, @corbacho! 🙏 Much appreciated, that concrete use case helps ensure we will settle on a solution here that takes that use case into account 👍
Comment #71
gabesullice@corbacho, would it be possible to make Gatsby work using a
Accept-Languageheader and not use a different URL/query param per language? That would be what I would like to implement here.Comment #72
robertoperuzzoI would like to share a rough solution I thought yesterday to fix node translation in our multilingual site project. We use different endpoint to "PATCH" node in different language:
In our site the default language is IT and we noticed that: if the node has no EN translation the PATCH request overwrite the IT node values. So I decided to intercept the jsonapi call and check if that node has a translation, if not, I create it.
Probably you can suggest me a better way to fix that. Here is my event subscriber snippet:
Comment #73
wim leers#72: thanks for sharing that! 🙏 But it's about the
hal_jsonformat provided by core'srestmodule, not thejsonapicontrib module :) Feel free to leave that comment here, but could you repeat it in #2135829: EntityResource: translations support? Thanks!This is still very valuable to keep here, because it highlights exactly the reason why I want to make sure we have a plan for not just reading translations, but also writing translations. Because otherwise edge cases like the one you encountered won't be taken into account and may be impossible to solve without breaking backwards compatibility.
Comment #74
bbuchert commentedJust to add my usecase here:
It would make sense to also include a property that indicates which translations of a page are available. If I built a language switch that would be really helpful to only show available translations there. And not having to make an extra request for that.
Comment #75
wim leersAbsolutely! The good news is that that's exactly what #2994193: [META] The Last Link: A Hypermedia Story is about! IOW: for any given entity, there'll be links to the other available translations, much like
content_translation_page_attachments()does for HTML responses Many details still to be worked out, especially for cases like possible translations but that don't exist yet, or translations with automatic fallbacks (e.g. U.K. English falling back to U.S. English).It's great to have explicit use cases listed here, so don't be shy, keep commenting :)
Comment #76
corbacho commented@gabesullice
Yes, it could work. Gatsby drupal plugin is implemented with axios, so it's a matter of adding the header to the axios request.
A minor negative consequence would be that it makes impossible to see/debug JSON API language issues directly in the browser, true? you would need Postman, or some tool to see JSON API responses per language, but it's acceptable.
Comment #77
wim leersThis issue was in desperate need of an issue summary update!
Also marking this a "plan" issue, making this a sibling of #2795279: [PP-2] [META] Revisions support.
Comment #78
wim leersComment #79
wim leersI think this may be a more accurate title.
Comment #80
xjmYesterday I discussed this issue with @Wim Leers, @gabesullice, and @effulgentsia, and we came up with the need for at least #3037804: Document the extent of JSON:API's multilingual support. and
I've since discussed this issue with both @larowlan and @gaborhojstsy. Since core REST suffers from similar problems, we are leaning toward making an exception to policy and adding JSON:API to core even with these feature gaps in place.
Some additional concerns we have before core inclusion (via @gaborhojtsy):
Comment #81
wim leersIt wants to keep everything working that happens to work automagically today.
No, that's what we have this issue for.
No. What works only happens to work due to magic in core's entity route param converter.
Comment #82
plachDisclaimer: all the following reasoning is based on the IS, I didn't have a look at the code and I'm not familiar at all with it, so take it for what it's worth :)
@xjm asked me to have a look at this with particular focus on data loss/integrity issues. I think an obvious candidate for more scrutiny is the following remaining task:
In particular, I think it's concerning that we are silently applying fallback rules while performing update and delete operations. Drupal's HTML UI does apply fallback rules as well, but performs a GET first, so users have a chance to notice that they are dealing with a translation not matching the content language: both on the entity form and the deletion confirmation form this is explicitly signaled. In this case we might be happily performing changes to the wrong target without the client even noticing it, until the next GET.
Additionally, deleting a translation and deleting the whole entity are very different operations, so we can't really apply a DELETE operation to a non-default translation and fall back to the default one if the former is missing, because that would mean deleting all existing translations and the entity itself.
I'm wondering whether a possible mitigation strategy while we figure out a proper way forward (the solution proposed in the IS makes sense to me FWIW), could be to ignore the fallback logic in controllers responsible to handle the PATCH and DELETE requests and return a 404 if an explicit check reveals that there is no translation matching the negotiated content language.
POST operations do not seem as much concerning at first sight, since there is no fallback possible given that no translation exists yet.
Comment #83
wim leers(Writing this on my phone.)
I realized after posting my last comment that I hadn't said I'd be happy to work on test coverage for this.
#82: Note that JSON:API behaves *identically* to core's REST module wrt translations. I'd be fine with implementing the mitigation strategy you propose, but note that some sites (I expect it to be an extremely tiny number though) may be relying on the current automagic behavior.
Comment #84
plachI'm not sure whether that's a good reason to add another way to break things, if that's actually the case, of course :)
Could it be possible to opt-out via a setting or somehow conditionally enable the fix?
Comment #85
plachBtw, tests to confirm that what #82 is describing actually happens (or not) would be more than welcome :)
Comment #86
xjmThanks @Wim Leers and @plach.
Re:
For core, our best practice is to place
@todosreferencing the issue node in the relevant code paths and in the tests where the behavior needs to be changed. So it would be good to add those if possible.Do we have core criticals already for the REST translation issues?
I think it's worth considering #83 and #84 (although #84 may be tricky if configuration is disallowed). However, we've made changes before to deny access for update operations that worked sometimes but caused data loss other times, as temporary workarounds until criticals are fixed. Either way, it sounds like maybe we'd want to have issues to do the same to REST as well -- or maybe there is a way to patch core to temporarily mitigate both until it's fixed?
Tests are definitely a good first step either way.
Comment #87
wim leersI completely forgot that we already do have explicit test coverage for what is supported WRT translations! 😅Hurray!
See
\Drupal\Tests\jsonapi\Functional\JsonApiFunctionalMultilingualTest, which has existed since January 2017, it was committed in #31. The issue was reopened shortly after that due to concerns about relying on the automagic context-dependent translation loading.Working to expand that now.
Comment #88
wim leersThanks for saying that in #83, that's great to know for the future, when we make JSON:API's translation support feature-complete :)
Done: #3038308: Avoid translations DELETE data loss and unintended changes with PATCH and test all methods against entity route parameter translation upcasting. A sequence of tiny interdiffs with focused tests per use case is awaiting, and I’ve posted my analysis + proposals: https://www.drupal.org/project/jsonapi/issues/3038308
Comment #89
wim leers#3038308: Avoid translations DELETE data loss and unintended changes with PATCH and test all methods against entity route parameter translation upcasting landed, after @plach RTBC'd it! 🎉
Comment #90
niklanComment deleted, move to separate issue as asked in #91.
Comment #91
wim leers#90/@Niklan: Could you please post that as a separate issue? This issue is for planning, not for concrete questions. Thanks!
Comment #92
e0ipsoDuring DrupalCon we talked about adding hypermedia support for translation. Ideally we would have a standard way to link to the translation versions of an entity in the document.
Comment #93
e0ipsoComment #94
tresti88Copying from an initial slack conversation
"So i have loosely looked at Spryker and Akeno. It looks like their implementations are slightly different in terms of dealing with different locals. Akeno uses the following format within attributes which i think mostly conforms to the JSON:API specification? although i'm not sure if having data as an object property is right? If we were to implement this type of approach then we would probably have to refactor the `GET` logic so it matches with this format. This would work for when we create `POST` and `PATCH` requests as we would know which entity the translations get attached to. This definitely feels like it could work.
In Spryker they use an `Accept-Language` header that's used to get an entity for that language, this is kind of similar to how Drupal's JASON:API currently works albeit the difference being that the url has the language code in it. (@e0ipso mentioned that Drupal can be configured to do this too). Obviously if we were to use a `POST` request to create a 'French' version of an 'English' entity then one of the main issues is that we don't know which entity to attach the translation to unless we send some sort of relationship identifier in the request. Can we do this with the JSON:api spec? If not this might mean instead we use PATCH to create/update languages against an entity via the correct language endpoint or Accept-Language header. One thing to note is that we aren't actually creating new entity instances because we are just updating an entity although again this doesn't feel 100% correct. What are your thoughts?"
Comment #95
j1mb0b commentedI created a sandbox which is a decoupled module for JSON:API https://www.drupal.org/sandbox/kingmackenzie/3061283, similar in the way JSON:API Extra's is working in terms of overriding Normalizers but purpose-built to handle entity translation. It's inspired based on patches in this issue: https://www.drupal.org/project/drupal/issues/2135829
Comment #96
wim leersComment #98
arla commentedFiltering does not seem to take current language into account.
I have a news node whose
field_slugisdogin English andhundin Swedish. When I use JSON:API withfilter[field_slug]=hundthen that news node is in the result regardless of what language I call JSON:API in (i.e. both for/jsonapi/node/newsand/sv/jsonapi/node/news).Comment #100
spokjeI have a client who wishes to push translations in JSON:API forward and is willing to sponsor me to work on this.
To get to the actual (development) work, we first need to agree on what is the best way forward.
Below is my proposal on a way forward. Where applicable/possible I will try to explain why I've made specific choices that might differ from what was discussed (but not really agreed upon) in this very issue.
1) Use a query parameter for translations, let's stick with the already proposed
lang_code=[2-letter-langcode].I've decide not to go with the discussed
Accept-Languageheader. After a brief discussion with @gabesullice yesterday, we both think that the chance that CDNs/Proxies don't use/accept this specific header is too big.2) One URL to rule them all!
I propose to drop all the fallback magic used for the HTML side of Drupal, meaning we ignore all the language negotiation logic that's in place on a site. Which would mean that the endpoint URL
/jsonapi/foo/bar/will consistently be used for any kind of JSON:API call, in the case of working with translations with the above mentioned query parameterlang_code=[2-letter-langcode].As far as I can see this is the only way to get a consistent endpoint for any Drupal multilangual site no matter what Content language detection method is used on that site.
Also by not using the fallback logic we can get rid of the use of ContentEntityBase::getTranslationFromContext (which returns a fallback if a node doesn't have a translation) and use ContentEntityBase::getTranslation which only returns an entity when there is a translation in the requested language available. If not, an Exception is thrown.
The fallback magic is very useful when serving HTML pages and returning a 404-page will make the user experience bad, but in JSON:API land, where the
nerdycool kids hang out, I think we need a binary answer. You ask for a specific translated entity, you either get it or a "Does Not Exist", nothing else.3) Return a list of available translations in the meta of each entity, need validated by @bbuchert's comment in #74 and proposed by @gabesullice in #61
4) There will be no opt-in for this new behaviour described above.
This will be BC-breaking, but only for users already working with the current JSON:API for translations. Usage of JSON:API without the translations will be "Business As Usual". The added meta in 3) should not be breaking anything.
The reason for not using an opt-in is because the current translation JSON:API is flawed at best (See for example #98 and Language negotiation doesn't respect langcode filter). IMHO that would mean most/all people wanting to use the "new" translation JSON:API will opt-in anyway.
Also we would have the (again IMHO) "nasty" situation of supporting 2 solutions for the same functionality, where one is better than the other. Let alone this being confusing for users/developers and most probably create a lot of noise with support topics on d.o.
On top of that, if we do choose for an opt-in, at some point we have to deprecate one of the 2 solutions anyway, so I think we need to bite the bullet, and make the people who are currently using translations in JSON:API to update their code. (Side-note: By the looks of it people are forced to jump through hoops with the current setup anyway, and might end up with cleaner code, see for example #69)
Disclaimer: I am by no means a JSON:API nor multilingual expert, so there might/probably will be gigantic errors/loopholes in the above proposal. That's why I call upon "The Big Brains" around here to look and shoot at this proposal.
After we reach a consensus on the above, I plan to write up a proposal on the
POST+PATCH+DELETE, which should be a lot easier when the above is formalized.Then we (read I...) break up this whole monolith in sub-issues and we can move forward and get this done! (#FamousLastWords)
Comment #101
gabesulliceWow, thanks for the write up!
🎉
1) Yes, unfortunately, I think we (read I) can't keep trying to do the proactive content negotiation thing—as much as it pains me. @bradjones1 and I tried to make it work in #3028501: Enable header-based proactive content negotiation with optimizations and opt-outs available. and I think we figured out how to do it technically, but I don't think the internet is ready for it (see @Wim Leers comment #35.
2) I agree that getting rid of fallbacks is key. I also agree with your conclusion that
getTranslationFromContext()is great for HTML, but not an HTTP API. Therefore, I definitely agree that if a matching translation doesn't exist for a particular URL either because the langcode doesn't exist or because an appropriate translation doesn't exist then returning a404(not found) response is the right thing to do™3) My suggestion in #61 for a single link only applies to header-based neg. I don't think this link style will work because the
hrefdoesn't have a langcode in it. We'll need a link for each translation with a uniquehrefunless we want to go down the URI Template path (we probably don't).4) Breaking BC just ain't gonna fly 😞 (been there, done that). We're going to have to maintain BC for any URLs that aren't very obviously "broken". We may not need to maintain BC for filtering collections because, as you pointed out, those have very confusing behaviors, but we'll definitely have to preserve the fallback behaviors for
GETing individual resources. That likely means a BC flag to toggle between usingContentEntityBase::getTranslationFromContextandContentEntityBase::getTranslation.That BC flag doesn't have to be opt-in, per se. We can automatically "opt in" existing installations (which can then "opt out"). New installations of JSON:API would use then use the correct, long-term behavior.
Questions
/fr/jsonapi/...vs./jsonapi/...?langCode=frFinal thoughts
I'd like to wait a few days to get @e0ipso's thoughts about fallbacks.
I'd like to hear @Wim Leers' thoughts too.
Comment #102
cgoffin commentedBecause of the need to add translations using the JSON API, I started investigating and came with a solution. I hope it's a start to get this into core.
The JSON:API documentation states that a resource object is unique defined by a type and an id and since the uuid is the same for all translations of a resource I decided to add the translations using the PATCH method instead of the POST method. I see the translations as part of the resource object and not a different resource object.
To know if a translation needs to be created I use the langcode attribute (only if the entity type is translatable). If the langcode attribute is set, I create a new translation (if it doesn't exists already). Otherwise I change the loaded entity to the existing translation.
If this needs to be addressed in another ticket, please let me know.
Comment #103
bbralaWhen I was reading the proposal of Spokje the first thing i though is it would be awesome to know the available languages for a resource. I saw in #61 @gabesullice made a point on this. But couldn't we just use a convention for that?
So we at least get the value of knowing what languages are available for that content?
This might get a little confisuing though when looking at recourse lists in a certain language, since you wouldn't include resources that are not included in the selected language...
Just my 2cts
Comment #104
blazey commentedIt might be a good idea to make it possible to check access without actually translating the entity (either with getTranslationFromContext or the langcode argument).
Use case: jsonapi_views would be able to return different language versions in the same listing and still check access (see #3175437: Translations not applied).
Comment #106
wim leers👍
💯
💯
But didn't @gabesullice in #61 point out a problem with this approach? Plus, it's only for header
… as I was responding to the first 3 points, I was very worried about backwards compatibility. Fair point that the current design is broken. (I think I've always argued the same too, actually, and I think I was opposed to even adding this at all originally?) And it's indeed actively causing problems as you can see for example in #69's write-up.
But even then, we just cannot break BC. I do think that we can simply mirror the existing behavior by creating an additional event subscriber that is only enabled by default on existing sites, and it does continue to run the current
::getTranslationFromContext()behavior and then does a 301 redirect to the new URL. Then in Drupal 10 we'll be able to simply drop this event subscriber! This means all weirdness would be concentrated in a single place.#101:
Well … as the author of said comment … I do want to point out that this means we're choosing not to pursue the technically best solution for the sake of a single use case: anonymous JSON:API instances served over HTTP/1 without TLS, and even then only for users behind a crappy proxy. It is fair to question whether we want to support that scenario at all.
I think this is the best argument.
I think we should simply use langcodes that
hreflangprescribes: https://en.wikipedia.org/wiki/Hreflang. That means languages (ISO 639-1), countries (ISO 3166-1) and potentially also script variations (ISO 15924). We should definitely pay close attention to complying with these specs (and specifically with RFCs defining the behavior ofhreflang) rather than exposing Drupal's internal defaults.Comment #107
plachI got some funding to work on this and got in touch with @Wim Leers and @gabesullice. We had a call last Friday during which we discussed how to bring this forward. The main aspects we discussed were how to keep BC and how to handle the existing language negotiation logic.
We agreed that a possible way forward is to introduce a new experimental module (JSON:API Translation), that would implement all the new/missing logic. This would allow people to opt-in without requiring any additional configuration. The goal would be to deprecate the legacy (
GET) logic and merge the module into JSON:API in D10.Implementation-wise we agreed that it should be ok to rely on the
Accept-LanguageandContent-Languageheaders for the reasons stated at #3028501-35: Enable header-based proactive content negotiation with optimizations and opt-outs available.@gabesullice and I had a follow-up call during which we drafted a "spec" proposal for the entity translation API exposed by the JSON:API Translation module. You can find it at:
https://github.com/gabesullice/drupal-jsonapi-translations/blob/master/i...
I'll start working on the implementation outlined over there, so that we have something to look at, but of course feedback on this proposal would be welcome :)
Comment #108
plachFrom https://github.com/gabesullice/drupal-jsonapi-translations/blob/master/i...
Gabe and I would both go with option C, since that would both inform clients that the translation they requested is missing and allow them to decide whether performing the suggested redirect (to the default translation) or applying their own fallback logic.
Comment #109
wim leers#108: +1 to option C 👍
Thoughts:
404 Not Foundbut with410 Gone.Why does— ah because we're creating a new entity in a non-default translation!POSTing a translation result inLocation: /jsonapi/node/article/{id}without the?langquery string?langcodefield conform to the same spec/does it use the same values as theContent-Languageheader? It'd be good to double-check.P.S.: this is the first time I've ever seen
300 Multiple Choices🤓Comment #110
plachHere is a first patch for this. I was wondering whether a new issue would be in order, but actually all the relevant discussion is here and patch is not huge, so it maybe worth turning this meta into an operative issue. Reviews welcome :)
The patch defines a new experimental module which overrides the JSON:API controller with a translation-aware one. The language negotiated by core is simply ignored and all the actual translation negotiation logic is based on the spec draft. The main difference with the latter is that the query string parameter is not
lang, which apparently is not JSON:API-compliant, butlang_code.Also, wrt #108, the behavior implemented so far was A. This is of course not set in stone, but @Berdir brought up that not doing A could introduce performance issues, due to additional requests, especially when dealing with listings.
Listings is an area that the current patch does not address, since it wasn't covered by the spec Gabe and I came up with. Personally I think that we could use the freshly-introduced concept of translation as a standalone resource to provide language-specific listings:
GET /jsonapi/node/articlewould return a list of all nodes respecting the currentAccept-Languageheader + fallback to the default translation, basically option A from #108.GET /jsonapi/node/article?lang_code=frwould return a list of French translations (including default translations, i.e. articles originally created in French). Articles with no French translation would not be listed.I think the latter may address the use case presented in #69.
I did not work on test coverage yet, since I have a limited budget, this is my next task unless there are big concerns with the current patch. People wishing to test the patch may use the Postman collection I used and the attached DB dump, if they do not wish to set an installation up. Please remember to configure the collection variables if you use it.
@Wim Leers, #109:
Sorry, I missed your comment, so the patch does not include any of the following, but here are a few replies:
1: If versioning is enabled, it may be possible to tell whether a previous version had a translation that now is missing, however IIRC with pending revisions it may not always be possible to distinguish the case where a translation was removed from one where it was added but not published yet. Worth investigating more.
3:
Content-Languagerelies on the language tags specified in BCP 47, which in turn relies (also) on 2-digit ISO-639 language codes, which are the language codes of predefined languages exposed by the language subsystem. The latter is configurable, so actually it is possible to install custom languages with invalid language codes, but I don't think should be a concern of ours.4: It depends :) By default yes, however the site default language may be different from the one resulting from the default logic configured in the content language settings. For instance that may hard code a specific non-default language as the default content language. OTOH this logic would apply for all entities, regardless of how they are created, if the language value is missing, so it is basically what have been happening so far already.
Comment #111
plachRe #108, according to MDN, option A is recommended:
Comment #112
plachI forgot to mention this:
Currently only users with update entity access can perform CUD operations. I think we should allow translators, i.e. users not having update access to create, update, delete non-default translations (based on their permissions). These permissions are defined by the Content Translation module, so maybe it should also define the JSON:API routes to perform these operations? Or would it make more sense to make JSON:API's access control alterable so that CT can tweak it?
Comment #114
plachComment #116
gabesulliceThis is a nice helper.
Suggestion:
getEntityFromRequestInteresting, you've turned all these into request attributes... I assume I'll see why a little further into the review.
I really like these helpers that you've added.
Maybe check for
$request->attributes->has($key)and then run the callback if the attribute is missing. The current implementation means that if the value is trulyFALSEorNULLthe callback will have to run no matter what. E.g.:Nit: "Allows the translation of content entities via JSON:API endpoints."
I think so.
Oof, this is screaming that we have a missing abstraction in JSON:API proper (obviously, not your responsibility)
maybe add a
final?Thought: we must be sure that this is added in the
Varyresponse header.Thanks for using a constant here! Given the spec's recommendation, I think we should make this
langCode(i.e. camel-cased)Maybe we could use setter injection for this so that we don't have to copy all the arguments and
usestatements.I'm wondering if instead of overriding this method, we should add a protected helper method and only call it if this module is installed, then override that method in this class. I suspect it might create a looser coupling (not 100% sure).
This might be a little more helpful if we indicate that we determined that the client wanted a translation because for the request. E.g. "The request specified a preferred language, but the requested resource type does not support translations."
Additionally, I don't think this is the right exception. I've only ever seen
422used for something wrong in the body of an HTTP request, never the URL or headers. I think the word "entity" refers to the thing that's represented by the content of the request body, but I'm not 100% confident about that.I appreciate that you went for a more specific status code, but I think
400really is the most appropriate here.😍
Ran out of time to finish this review, but I'm posting what I have so far. More soon!
Comment #117
plachThanks, in the meantime I'm posting a minor update to fix an issue that was preventing the patch from working on case-sensitive file systems.
Comment #118
plachAnd now with 100% more patch attached!
Comment #120
plachComment #121
plachThis should fix cache support for GET requests. Now I think it might make sense to split this into a few subtasks.
Comment #123
plachComment #124
gabesulliceA bit more... overall, I am very impressed! I like where this is headed :)
Did you consider overriding
EntityResource::getEntityTranslation? If so, I assume you did not do that because it doesn't have access to the$request(we could change that)?JSON:API allows field names to be aliased, so we need to do a little limbo here, like so:
Should
jsonapi_translation_languagebe a static class constant too?Nit: Maybe
s/$header_langcode/$content_language_headerbecause while I was reviewing this I kept forgetting that this variable contained the value of theContent-Languageheader, not theAccept-Languageheader.I think we should throw a
400if$header_langcode && $request->isMethodCacheable()since thegetIndividual()method is calling$this->getResourceLanguage()to determine if a langcode was explicitly provided and we should not support a request like:As an accidental way to get the
frtranslation from the canonical URL.❤️
Same as above, I think this should be a plainThis is fine because it's a mismatch between the URL and the400status code.content-language, which, unlike above is about the request entity.Nit: maybe "The specified language ("%s") is invalid or has not been configured."
I know that this attribute will not be set on collection requests, but can we add a guard like this so that we never accidentally apply this to collection responses?
(We should probably add
getCardinalitytoTopLevelDataInterfacewhile we're at it so we don't need a check against the concrete class, but that could be a follow up)Comment #125
plachUpdated IS.
I will create a couple of children issues and move the related patches over there.
Comment #126
plachSmall fix to address some edge-cases related to UUID param conversion.
Be sure to rebuild caches after applying the patch.
Comment #127
plachCreated child issues:
Comment #128
franmako commentedI tried the latest patch at #126 on a project that I'm working on and it seems that the included fields that I've added to the default include list via the jsonapi_extras do not work, when the jsonapi_translation module is enabled.
Comment #129
gabesulliceThanks for reporting a problem @franmako! I think the issue metadata can stay the same though.
Comment #131
bbralaIt seems the patch doesn't apply anymore. Also #123 has a lot of feedback and i think that feedback has not been applied, so i'm setting this back to needs work.
Comment #133
bbrala#3199696: Add language support to ResourceObject has been committed :)
Un-postponing #3199697: Add JSON:API Translation experimental module
Comment #134
bbralaComment #137
b.khouyThis is a little bit confused, the final question still on the table: How to update an existing entity translation using Json:API PATCH method?
What headers/meta/attributes... should developers use in their PATCH request to determine the concerned translation after applying #126 patch?
I think a clear documentation is also needed for this topic.
Thanks guys
Comment #138
pozi commentedI am new to the topic and was able to add new translation by commenting out those lines from #126 patch:
@b.khouy you can add transaltion either by using queryParam "lang_code" or by setting POST request "Content-Language" header, of course both set to translation destination language. Sending PATCH request to whatever entity endpoint language modifies default language.
Comment #140
sam.foster commentedHey guys,
What is the current state of play for this issue? We have Drupal 8.9.2 and are using JSON:API to create English nodes, but we need to create translations of those nodes into Welsh. The Content Type is correctly set up to allow translation, and we can of course create a translation with the GUI, but is this now possible using the JSON:API module or via some other means?
Can any of these patches here be successfully applied to deliver such functionality? I've tried patching in the drupal/core section of my composer.json but the patch won't apply. Is that because it is now in the core or is it because the patches no longer work with the current version I have as part of Drupal 8.9.2?
Any pointers will be greatly appreciated
Cheers
Sam
Comment #141
sam.foster commentedGuys
Any update at all - can REST API actually create translated nodes and establish the relationship between the original node and the translated one?
If not are there any work arounds?
Surely someone out there knows something?
Thanks
Sam
Comment #142
bradjones1Hi Sam - This is not an area I'm actively working on, yet I can speak to your repeated requests for an update. Drupal is maintained by open-source contributors and there's no dedicated paid developers working on JSON:API specifically. You mention "REST API" which would be something different from JSON:API module, but i'm guessing you're talking about the items in this issue. There is some work on this already, as you can see on this issue... but it's waiting on people (maybe people like you?) to help finish it up.
I can say that if there are ever updates, you'll see them here. If this is important to your use case, you can help us get this done, or I'm sure there are developers experienced in this area who would be open to doing sponsored work.
Comment #143
bbralaA little note about this we need to take into consideration. If we add a query parameter, the JSON:API specification has some rules around them. They need to be a valid member name, but also contain a special character. See:
https://jsonapi.org/format/#query-parameters-custom
Comment #144
bradjones1Per #143 My read is that it must contain at least one non a-z character, but a capital letter suffices.
So not much of a strong requirement but something we should enforce.
Comment #145
bbralaWow you're right. Yay! :)
Comment #146
plach@bbrala @bradjones1
I resumed work on this today at the DrupalCon Lille sprint. Regarding #143 - #144, the latest code uses a
langCodeparameter. That looks compliant, doesn't it?Comment #147
bbralayeah langCode is fine.
Would've loved to be able to sit with you, but the fact this week is the kids vacation made that too hard :(
Comment #148
plach@gabesullice
Addressed your reviews at https://www.drupal.org/project/drupal/issues/3199697#mr1591-note220760