Skip to content

Commit 2102f1b

Browse files
authored
fix(content): use contentlet language for relationship hydration instead of session language (#35168)
## Summary - Fixes relationship fields returning default language content instead of localized content when using the `depth` parameter on `GET /api/v1/content/{inodeOrIdentifier}` - **Root cause**: When fetching content by inode without an explicit `language` query param, the backend derived `languageId` from the session (defaulting to language 1). The contentlet itself was resolved correctly via inode, but relationship hydration used the session language instead of the contentlet's actual language. - **Fix**: Use `contentlet.getLanguageId()` instead of the request-derived `languageId` when calling `ContentUtils.addRelationships`, ensuring relationships are always hydrated in the same language as the contentlet being returned. <img width="2718" height="2886" alt="CleanShot 2026-04-01 at 13 01 59@2x" src="https://github.com/user-attachments/assets/b4178389-b8ef-4096-a912-3077823e7459" /> Closes #34289 ## Acceptance Criteria - [x] API response respects the language of the requested content item when hydrating relationships - [x] When fetching by inode (no explicit language param), relationships match the contentlet's language - [x] When fetching by identifier with explicit language param, behavior is unchanged (contentlet resolves to the requested language, relationships match) - [x] Default language queries are not affected (backward compatible) ## Test Plan - [ ] Create a content type with a relationship field - [ ] Create content item "Parent" with English and Spanish versions - [ ] Create related content "Child EN" (English) and "Child ES" (Spanish) - [ ] Assign "Child EN" as relationship for English parent, "Child ES" for Spanish parent - [ ] Fetch English parent by inode with `?depth=2` → verify relationship returns English child - [ ] Fetch Spanish parent by inode with `?depth=2` → verify relationship returns Spanish child (previously returned English) - [ ] Fetch by identifier with `?language=2&depth=2` → verify relationship returns Spanish child ## Changed Files - `dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java` — Line 415: Changed `languageId` → `contentlet.getLanguageId()`
1 parent e746462 commit 2102f1b

3 files changed

Lines changed: 328 additions & 1 deletion

File tree

dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ public Response getContent(@Context HttpServletRequest request,
412412

413413
if (-1 != depth) {
414414
ContentUtils.addRelationships(contentlet, user, mode,
415-
languageId, depth, request, response);
415+
contentlet.getLanguageId(), depth, request, response);
416416
}
417417
final String variant = contentlet.getVariantId();
418418
contentlet = new DotTransformerBuilder().contentResourceOptions(false).content(contentlet).build().hydrate().get(0);

dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
com.dotcms.rest.api.v1.page.PageResourceTest.class,
7373
com.dotcms.rest.api.v1.temp.TempFileResourceTest.class,
7474
com.dotcms.rest.api.v1.content.ContentVersionResourceIntegrationTest.class,
75+
com.dotcms.rest.api.v1.content.ContentResourceIntegrationTest.class,
7576
com.dotcms.rest.api.v1.container.ContainerResourceIntegrationTest.class,
7677
com.dotcms.rest.api.v1.container.ContainerResourceHostResolutionIT.class,
7778
com.dotcms.rest.api.v1.theme.ThemeResourceIntegrationTest.class,
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
package com.dotcms.rest.api.v1.content;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertNotNull;
5+
import static org.junit.Assert.assertTrue;
6+
import static org.mockito.Mockito.mock;
7+
8+
import com.dotcms.contenttype.model.field.RelationshipField;
9+
import com.dotcms.contenttype.model.type.ContentType;
10+
import com.dotcms.datagen.ContentTypeDataGen;
11+
import com.dotcms.datagen.ContentletDataGen;
12+
import com.dotcms.datagen.FieldDataGen;
13+
import com.dotcms.datagen.TestDataUtils;
14+
import com.dotcms.mock.request.MockAttributeRequest;
15+
import com.dotcms.mock.request.MockHeaderRequest;
16+
import com.dotcms.mock.request.MockHttpRequestIntegrationTest;
17+
import com.dotcms.mock.request.MockSessionRequest;
18+
import com.dotcms.rest.ResponseEntityView;
19+
import com.dotcms.util.IntegrationTestInitService;
20+
import com.dotmarketing.business.APILocator;
21+
import com.dotmarketing.portlets.contentlet.model.Contentlet;
22+
import com.dotmarketing.portlets.contentlet.model.IndexPolicy;
23+
import com.dotmarketing.portlets.languagesmanager.model.Language;
24+
import com.dotmarketing.portlets.structure.model.Relationship;
25+
import com.dotmarketing.util.Config;
26+
import com.dotmarketing.util.WebKeys.Relationship.RELATIONSHIP_CARDINALITY;
27+
import com.liferay.portal.model.User;
28+
import java.util.Base64;
29+
import java.util.List;
30+
import java.util.Map;
31+
import javax.servlet.http.HttpServletRequest;
32+
import javax.servlet.http.HttpServletResponse;
33+
import javax.ws.rs.core.Response;
34+
import javax.ws.rs.core.Response.Status;
35+
import org.junit.BeforeClass;
36+
import org.junit.Test;
37+
38+
/**
39+
* Integration tests for {@link ContentResource} (v1 endpoint: /api/v1/content).
40+
*/
41+
public class ContentResourceIntegrationTest {
42+
43+
private static User systemUser;
44+
45+
@BeforeClass
46+
public static void prepare() throws Exception {
47+
IntegrationTestInitService.getInstance().init();
48+
systemUser = APILocator.getUserAPI().getSystemUser();
49+
}
50+
51+
/**
52+
* Method to test: {@link ContentResource#getContent}
53+
* <p>
54+
* Given scenario:
55+
* <ul>
56+
* <li>Parent content type with a relationship field to a child content type</li>
57+
* <li>Child content exists ONLY in default language (EN)</li>
58+
* <li>Parent content exists ONLY in default language (EN), related to the child</li>
59+
* <li>DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE is enabled (fallback active)</li>
60+
* <li>Request the parent with a non-default language (Spanish) and depth=1</li>
61+
* </ul>
62+
* <p>
63+
* Expected result: The parent should resolve via fallback to the default language,
64+
* and the relationship should be hydrated using the contentlet's actual language (EN),
65+
* returning the related child content correctly.
66+
* <p>
67+
* Before the fix, the relationship was hydrated using the request's language (Spanish),
68+
* which could fail to find the related content in that language.
69+
*/
70+
@Test
71+
public void test_getContent_withFallbackLanguage_shouldHydrateRelationshipsWithContentletLanguage()
72+
throws Exception {
73+
74+
final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage();
75+
final Language spanishLanguage = TestDataUtils.getSpanishLanguage();
76+
77+
// Save original config and enable fallback
78+
final boolean originalConfig = Config.getBooleanProperty(
79+
"DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE", false);
80+
Config.setProperty("DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE", true);
81+
82+
try {
83+
// Create child content type (simple: title)
84+
final ContentType childContentType = new ContentTypeDataGen()
85+
.fields(List.of(
86+
new FieldDataGen().name("Title")
87+
.velocityVarName("title").next()
88+
))
89+
.nextPersisted();
90+
91+
// Create parent content type with relationship field to child
92+
final ContentType parentContentType = new ContentTypeDataGen()
93+
.fields(List.of(
94+
new FieldDataGen().name("Title")
95+
.velocityVarName("title").next(),
96+
new FieldDataGen().type(RelationshipField.class)
97+
.name("Children")
98+
.velocityVarName("children")
99+
.relationType(childContentType.variable())
100+
.values(String.valueOf(
101+
RELATIONSHIP_CARDINALITY.ONE_TO_MANY.ordinal()))
102+
.next()
103+
))
104+
.nextPersisted();
105+
106+
// Get the relationship object
107+
final com.dotcms.contenttype.model.field.Field relationshipField =
108+
parentContentType.fields(RelationshipField.class).get(0);
109+
final Relationship relationship = APILocator.getRelationshipAPI()
110+
.getRelationshipFromField(relationshipField, systemUser);
111+
112+
// Create child content in default language (EN) only
113+
final Contentlet childEN = new ContentletDataGen(childContentType.id())
114+
.languageId(defaultLanguage.getId())
115+
.setProperty("title", "Child EN Title")
116+
.nextPersisted();
117+
118+
// Create parent content in default language (EN) with relationship to child
119+
Contentlet parentEN = new ContentletDataGen(parentContentType.id())
120+
.languageId(defaultLanguage.getId())
121+
.setProperty("title", "Parent EN Title")
122+
.next();
123+
parentEN.setIndexPolicy(IndexPolicy.FORCE);
124+
parentEN = APILocator.getContentletAPI().checkin(parentEN,
125+
Map.of(relationship, List.of(childEN)), systemUser, false);
126+
127+
// Create new ContentResource instance (gets fresh Lazy config evaluation)
128+
final ContentResource contentResource = new ContentResource();
129+
130+
// Build request with admin auth
131+
final HttpServletRequest request = createAuthenticatedRequest();
132+
final HttpServletResponse response = mock(HttpServletResponse.class);
133+
134+
// Request the parent with SPANISH language and depth=1
135+
// Parent doesn't exist in Spanish -> fallback to EN
136+
// Fix ensures relationships are hydrated with EN (contentlet's language),
137+
// not Spanish (request's language)
138+
final Response endpointResponse = contentResource.getContent(
139+
request, response,
140+
parentEN.getIdentifier(),
141+
String.valueOf(spanishLanguage.getId()),
142+
"DEFAULT",
143+
1);
144+
145+
// Verify the response is successful
146+
assertEquals(Status.OK.getStatusCode(), endpointResponse.getStatus());
147+
148+
// Extract the contentlet map from the response
149+
@SuppressWarnings("unchecked")
150+
final ResponseEntityView<Map<String, Object>> entityView =
151+
(ResponseEntityView<Map<String, Object>>) endpointResponse.getEntity();
152+
final Map<String, Object> contentletMap = entityView.getEntity();
153+
154+
assertNotNull("Response map should not be null", contentletMap);
155+
156+
// Verify the contentlet resolved in default language (EN)
157+
assertEquals("Contentlet should be in default language",
158+
defaultLanguage.getId(),
159+
Long.parseLong(contentletMap.get("languageId").toString()));
160+
161+
// Verify the relationship field is present and contains the child
162+
final Object relationshipValue = contentletMap.get(relationshipField.variable());
163+
assertNotNull("Relationship field should be present in response",
164+
relationshipValue);
165+
166+
assertTrue("Relationship value should be a list",
167+
relationshipValue instanceof List);
168+
169+
@SuppressWarnings("unchecked")
170+
final List<Map<String, Object>> relatedItems =
171+
(List<Map<String, Object>>) relationshipValue;
172+
assertEquals("Should have one related child", 1, relatedItems.size());
173+
174+
final Map<String, Object> relatedChild = relatedItems.get(0);
175+
assertEquals("Related child should have the correct identifier",
176+
childEN.getIdentifier(), relatedChild.get("identifier"));
177+
assertEquals("Related child should be in default language",
178+
defaultLanguage.getId(),
179+
Long.parseLong(relatedChild.get("languageId").toString()));
180+
181+
} finally {
182+
Config.setProperty("DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE", originalConfig);
183+
}
184+
}
185+
186+
/**
187+
* Method to test: {@link ContentResource#getContent}
188+
* <p>
189+
* Given scenario:
190+
* <ul>
191+
* <li>Parent content type with a relationship field to a child content type</li>
192+
* <li>Child content exists in both EN and ES</li>
193+
* <li>Parent content exists in both EN and ES, related to the child</li>
194+
* <li>Request the parent with Spanish language and depth=1</li>
195+
* </ul>
196+
* <p>
197+
* Expected result: The parent should resolve in Spanish, and the relationship
198+
* should return the Spanish version of the related child.
199+
*/
200+
@Test
201+
public void test_getContent_withMultiLanguageContent_shouldReturnRelationshipsInRequestedLanguage()
202+
throws Exception {
203+
204+
final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage();
205+
final Language spanishLanguage = TestDataUtils.getSpanishLanguage();
206+
207+
// Create child content type
208+
final ContentType childContentType = new ContentTypeDataGen()
209+
.fields(List.of(
210+
new FieldDataGen().name("Title")
211+
.velocityVarName("title").next()
212+
))
213+
.nextPersisted();
214+
215+
// Create parent content type with relationship field to child
216+
final ContentType parentContentType = new ContentTypeDataGen()
217+
.fields(List.of(
218+
new FieldDataGen().name("Title")
219+
.velocityVarName("title").next(),
220+
new FieldDataGen().type(RelationshipField.class)
221+
.name("Children")
222+
.velocityVarName("children")
223+
.relationType(childContentType.variable())
224+
.values(String.valueOf(
225+
RELATIONSHIP_CARDINALITY.ONE_TO_MANY.ordinal()))
226+
.next()
227+
))
228+
.nextPersisted();
229+
230+
// Get the relationship object
231+
final com.dotcms.contenttype.model.field.Field relationshipField =
232+
parentContentType.fields(RelationshipField.class).get(0);
233+
final Relationship relationship = APILocator.getRelationshipAPI()
234+
.getRelationshipFromField(relationshipField, systemUser);
235+
236+
// Create child content in EN
237+
final Contentlet childEN = new ContentletDataGen(childContentType.id())
238+
.languageId(defaultLanguage.getId())
239+
.setProperty("title", "Child EN Title")
240+
.nextPersisted();
241+
242+
// Create child content in ES (same identifier, different language)
243+
final Contentlet childESCheckout = ContentletDataGen.checkout(childEN);
244+
childESCheckout.setLanguageId(spanishLanguage.getId());
245+
childESCheckout.setProperty("title", "Child ES Title");
246+
final Contentlet childES = ContentletDataGen.checkin(childESCheckout);
247+
248+
// Create parent content in EN with relationship to child
249+
Contentlet parentEN = new ContentletDataGen(parentContentType.id())
250+
.languageId(defaultLanguage.getId())
251+
.setProperty("title", "Parent EN Title")
252+
.next();
253+
parentEN.setIndexPolicy(IndexPolicy.FORCE);
254+
parentEN = APILocator.getContentletAPI().checkin(parentEN,
255+
Map.of(relationship, List.of(childEN)), systemUser, false);
256+
257+
// Create parent content in ES with relationship to child ES
258+
Contentlet parentESCheckout = ContentletDataGen.checkout(parentEN);
259+
parentESCheckout.setLanguageId(spanishLanguage.getId());
260+
parentESCheckout.setProperty("title", "Parent ES Title");
261+
parentESCheckout.setIndexPolicy(IndexPolicy.FORCE);
262+
final Contentlet parentES = APILocator.getContentletAPI().checkin(parentESCheckout,
263+
Map.of(relationship, List.of(childES)), systemUser, false);
264+
265+
final ContentResource contentResource = new ContentResource();
266+
final HttpServletRequest request = createAuthenticatedRequest();
267+
final HttpServletResponse response = mock(HttpServletResponse.class);
268+
269+
// Request the parent with Spanish language and depth=1
270+
final Response endpointResponse = contentResource.getContent(
271+
request, response,
272+
parentEN.getIdentifier(),
273+
String.valueOf(spanishLanguage.getId()),
274+
"DEFAULT",
275+
1);
276+
277+
assertEquals(Status.OK.getStatusCode(), endpointResponse.getStatus());
278+
279+
@SuppressWarnings("unchecked")
280+
final ResponseEntityView<Map<String, Object>> entityView =
281+
(ResponseEntityView<Map<String, Object>>) endpointResponse.getEntity();
282+
final Map<String, Object> contentletMap = entityView.getEntity();
283+
284+
assertNotNull("Response map should not be null", contentletMap);
285+
286+
// Verify the contentlet resolved in Spanish
287+
assertEquals("Contentlet should be in Spanish",
288+
spanishLanguage.getId(),
289+
Long.parseLong(contentletMap.get("languageId").toString()));
290+
291+
// Verify the relationship returns the Spanish child
292+
final Object relationshipValue = contentletMap.get(relationshipField.variable());
293+
assertNotNull("Relationship field should be present", relationshipValue);
294+
assertTrue("Relationship value should be a list",
295+
relationshipValue instanceof List);
296+
297+
@SuppressWarnings("unchecked")
298+
final List<Map<String, Object>> relatedItems =
299+
(List<Map<String, Object>>) relationshipValue;
300+
assertEquals("Should have one related child", 1, relatedItems.size());
301+
302+
final Map<String, Object> relatedChild = relatedItems.get(0);
303+
assertEquals("Related child should have correct identifier",
304+
childEN.getIdentifier(), relatedChild.get("identifier"));
305+
assertEquals("Related child should be in Spanish",
306+
spanishLanguage.getId(),
307+
Long.parseLong(relatedChild.get("languageId").toString()));
308+
}
309+
310+
/**
311+
* Creates an authenticated HttpServletRequest with admin credentials.
312+
*/
313+
private static HttpServletRequest createAuthenticatedRequest() {
314+
final MockHeaderRequest request = new MockHeaderRequest(
315+
new MockSessionRequest(
316+
new MockAttributeRequest(
317+
new MockHttpRequestIntegrationTest("localhost", "/").request()
318+
).request()
319+
).request()
320+
);
321+
request.setHeader("Authorization",
322+
"Basic " + Base64.getEncoder().encodeToString(
323+
"[email protected]:admin".getBytes()));
324+
return request;
325+
}
326+
}

0 commit comments

Comments
 (0)