Skip to content

Commit 6e003eb

Browse files
fix(Graphql): align vanityUrls resolution with Page API behavior dotCMS#32164 (dotCMS#32211)
⚠️ **Breaking Change Notice** This change introduces a breaking behavior adjustment in the GraphQL `page` query to align its handling of **Vanity URLs** with the behavior of the **REST Page API**. ### Background On the REST `/api/v1/page/render/{uri}` endpoint: - If the requested URL is associated with a **temporary** or **permanent redirect**, the response contains: - An **empty** `page` object, and - A `vanityUrl` object describing the redirect (`forwardTo`, `action`, etc.). - If the Vanity URL is a **200 forward**, the response contains: - The **resolved page** (i.e., the target of the forward), and - The corresponding `vanityUrl` metadata. ### Previous Behavior in GraphQL (Inconsistent) When querying a page using a Vanity URL (e.g., `page(url: "/vanity-redirect")`), GraphQL would: - Return the `page` object of the **original URI**, even for redirects. - Also include the `vanityUrl` info. - It did **not** respect the redirect semantics (301/302), nor return an empty page as expected. ### New Behavior (Aligned with Page API) After this update: - For **temporary** or **permanent redirects**, GraphQL will return: - An **empty** `page` object, and - A `vanityUrl` object with proper redirect metadata. - For **200 forwards**, GraphQL will return: - The **resolved page content** (from the target of the forward), and - The associated `vanityUrl`. ### Impact Clients that rely on the GraphQL `page` query **will be affected** when querying URIs that are referenced by Vanity URLs. Specifically: - If the URI is associated with a **temporary (302)** or **permanent (301)** redirect: - The `page` object will now be **empty**, and - Only the `vanityUrl` metadata will be returned. - If the URI is associated with a **forward (200)**: - The returned `page` will represent the **resolved target page**, not the original URI. This change ensures GraphQL behaves consistently with the REST Page API, but may require consumers to update their logic to properly handle redirects or forwards based on `vanityUrl.action`. --- ### Proposed Changes - **PageDataFetcher** - Hook into the Vanity-URL API before rendering: 1. Resolve via `APILocator.getVanityUrlAPI().resolveVanityUrl(...)` 2. **Redirects**: return an “empty” `Contentlet` (so GraphQL can serialize `vanityUrl` but no page data) 3. **Forwards**: rewrite the fetch URI and continue to render the target page - Store the resolved `CachedVanityUrl` in the GraphQL context so downstream `VanityURLFetcher` can pick it up. - **VanityURLFetcher** - First check for a `cachedVanityUrl` in the GraphQL context and return it directly if present, falling back to the normal lookup otherwise. ### Checklist - [x] Tests - [x] Translations - [x] Security Implications Contemplated (add notes if applicable) ### Additional Info - Matches the REST behavior in `/api/v1/page/render/{uri}` - Verified manually in GraphQL Playground with three scenarios: ```graphql query VanityTest { forward: page(url: "/vanity-forward") { pageURI url vanityUrl { forwardTo uri action } } temporary: page(url: "/vanity-temporary-redirect") { pageURI url vanityUrl { forwardTo uri action } } permanent: page(url: "/vanity-permanent-redirect") { pageURI url vanityUrl { forwardTo uri action } } urlContentMapForward: page(url: "/blog/post/french-polynesia-everything-you-need-to-know") { pageURI url vanityUrl { forwardTo uri action } } } ![image](https://github.com/user-attachments/assets/efb073ec-946c-40e1-a7ed-1469cf777040)
1 parent 302b81c commit 6e003eb

File tree

5 files changed

+1036
-62
lines changed

5 files changed

+1036
-62
lines changed

dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FieldDataFetcher.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.dotcms.contenttype.model.field.DataTypes;
88
import com.dotcms.contenttype.model.field.Field;
99
import com.dotcms.contenttype.model.field.TextField;
10+
import com.dotcms.contenttype.model.type.ContentType;
1011
import com.dotcms.graphql.DotGraphQLContext;
1112
import com.dotmarketing.portlets.contentlet.model.Contentlet;
1213
import com.dotmarketing.util.Logger;
@@ -27,7 +28,12 @@ public Object get(final DataFetchingEnvironment environment) throws Exception {
2728
Logger.debug(this, ()-> "Fetching field for contentlet: " + contentlet.getIdentifier() + " field: " + var);
2829
Object fieldValue = contentlet.get(var);
2930

30-
final Field field = contentlet.getContentType().fieldMap().get(var);
31+
final ContentType contentType = contentlet.getContentType();
32+
if (contentType == null) {
33+
Logger.debug(this, ()-> "No ContentType on contentlet " + contentlet.getIdentifier() + ", returning raw value");
34+
return fieldValue;
35+
}
36+
final Field field = contentType.fieldMap().get(var);
3137

3238
if(!UtilMethods.isSet(fieldValue)) {
3339
// null value - maybe a constant field

dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/PageDataFetcher.java

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package com.dotcms.graphql.datafetcher.page;
22

3+
import com.dotcms.contenttype.model.type.ImmutablePageContentType;
34
import com.dotcms.graphql.DotGraphQLContext;
45
import com.dotcms.graphql.exception.PermissionDeniedGraphQLException;
56
import com.dotcms.rest.api.v1.page.PageResource;
6-
import com.dotcms.variant.VariantAPI;
7+
import com.dotcms.vanityurl.business.VanityUrlAPI;
8+
import com.dotcms.vanityurl.model.CachedVanityUrl;
9+
import com.dotcms.vanityurl.model.VanityUrlResult;
710
import com.dotmarketing.beans.Host;
811
import com.dotmarketing.business.APILocator;
12+
import com.dotmarketing.business.web.WebAPILocator;
913
import com.dotmarketing.exception.DotSecurityException;
1014
import com.dotmarketing.portlets.contentlet.model.Contentlet;
1115
import com.dotmarketing.portlets.contentlet.transform.DotContentletTransformer;
@@ -15,6 +19,7 @@
1519
import com.dotmarketing.portlets.htmlpageasset.business.render.PageContext;
1620
import com.dotmarketing.portlets.htmlpageasset.business.render.PageContextBuilder;
1721
import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset;
22+
import com.dotmarketing.portlets.languagesmanager.model.Language;
1823
import com.dotmarketing.portlets.rules.business.RulesEngine;
1924
import com.dotmarketing.portlets.rules.model.Rule.FireOn;
2025
import com.dotmarketing.util.DateUtil;
@@ -31,6 +36,7 @@
3136
import java.util.Date;
3237
import javax.servlet.http.HttpServletRequest;
3338
import javax.servlet.http.HttpServletResponse;
39+
import java.util.Optional;
3440

3541
/**
3642
* This DataFetcher returns a {@link HTMLPageAsset} given an URL. It also takes optional parameters
@@ -102,11 +108,42 @@ public Contentlet get(final DataFetchingEnvironment environment) throws Exceptio
102108
}
103109
}
104110

111+
// Vanity URL resolution
112+
String resolvedUri = url;
113+
final Language language = UtilMethods.isSet(languageId) ?
114+
APILocator.getLanguageAPI().getLanguage(languageId) : APILocator.getLanguageAPI().getDefaultLanguage();
115+
final Host host = WebAPILocator.getHostWebAPI().getHost(request);
116+
117+
final Optional<CachedVanityUrl> cachedVanityUrlOpt = APILocator.getVanityUrlAPI()
118+
.resolveVanityUrl(url, host, language);
119+
120+
if (cachedVanityUrlOpt.isPresent()) {
121+
response.setHeader(VanityUrlAPI.VANITY_URL_RESPONSE_HEADER,
122+
cachedVanityUrlOpt.get().vanityUrlId);
123+
124+
// Store the CachedVanityUrl in the context
125+
context.addParam("cachedVanityUrl", cachedVanityUrlOpt.get());
126+
127+
if (cachedVanityUrlOpt.get().isTemporaryRedirect() ||
128+
cachedVanityUrlOpt.get().isPermanentRedirect()) {
129+
// For redirects, return an empty page with vanity URL info
130+
final Contentlet emptyPage = new Contentlet();
131+
emptyPage.setLanguageId(language.getId());
132+
emptyPage.setHost(host.getIdentifier());
133+
return emptyPage;
134+
} else {
135+
// For forwards, use the resolved URI
136+
final VanityUrlResult vanityUrlResult = cachedVanityUrlOpt.get()
137+
.handle(url);
138+
resolvedUri = vanityUrlResult.getRewrite();
139+
}
140+
}
141+
105142
Logger.debug(this, ()-> "Fetching page for URL: " + url);
106143

107144
final PageContext pageContext = PageContextBuilder.builder()
108145
.setUser(user)
109-
.setPageUri(url)
146+
.setPageUri(resolvedUri)
110147
.setPageMode(mode)
111148
.setGraphQL(true)
112149
.build();
@@ -130,6 +167,8 @@ public Contentlet get(final DataFetchingEnvironment environment) throws Exceptio
130167
final HTMLPageAsset pageAsset = pageUrl.getHTMLPage();
131168
context.addParam("page", pageAsset);
132169
pageAsset.getMap().put("URLMapContent", pageUrl.getUrlMapInfo());
170+
171+
133172
if(fireRules) {
134173
Logger.info(this, "Rules will be fired");
135174
request.setAttribute("fireRules", true);

dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/VanityURLFetcher.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ public class VanityURLFetcher implements DataFetcher<CachedVanityUrl> {
2424

2525
@Override
2626
public CachedVanityUrl get(final DataFetchingEnvironment environment) throws Exception {
27-
2827
final DotGraphQLContext context = environment.getContext();
28+
29+
// First check if we already have the cached vanity URL from PageDataFetcher
30+
CachedVanityUrl cachedVanityUrl = (CachedVanityUrl) context.getParam("cachedVanityUrl");
31+
if (cachedVanityUrl != null) {
32+
return cachedVanityUrl;
33+
}
34+
2935
final User user = context.getUser();
3036
final Contentlet page = environment.getSource();
3137
final Host host = APILocator.getHostAPI().find(
@@ -39,6 +45,7 @@ public CachedVanityUrl get(final DataFetchingEnvironment environment) throws Exc
3945
if (!UtilMethods.isSet(uri)) {
4046
uri = page.getStringProperty("url");
4147
}
48+
4249
if (UtilMethods.isSet(uri)) {
4350
final Optional<CachedVanityUrl> vanityUrlOpt = APILocator.getVanityUrlAPI().
4451
resolveVanityUrl(uri, host, language);
@@ -49,5 +56,4 @@ public CachedVanityUrl get(final DataFetchingEnvironment environment) throws Exc
4956

5057
return null;
5158
}
52-
5359
}

dotcms-integration/src/test/java/com/dotcms/graphql/datafetcher/page/VanityURLFetcherTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.dotcms.graphql.DotGraphQLContext;
1010
import com.dotcms.util.FiltersUtil;
1111
import com.dotcms.util.IntegrationTestInitService;
12+
import com.dotcms.vanityurl.model.CachedVanityUrl;
1213
import com.dotmarketing.beans.Host;
1314
import com.dotmarketing.business.APILocator;
1415
import com.dotmarketing.business.UserAPI;
@@ -153,4 +154,38 @@ public void testGet_WithNonExitingVanityURL() throws Exception {
153154
assertNull(cachedVanityUrl);
154155
}
155156

157+
/**
158+
* MethodToTest: {@link VanityURLFetcher#get(DataFetchingEnvironment)}
159+
* Given Scenario: Context already includes a cached vanity URL
160+
* Expected Result: Should return the cached vanity URL without resolving it again
161+
*/
162+
@Test
163+
public void testGet_WithCachedVanityUrlInContext() throws Exception {
164+
final var fetcher = new VanityURLFetcher();
165+
166+
final var environment = Mockito.mock(DataFetchingEnvironment.class);
167+
final var preCachedVanityUrl = new CachedVanityUrl(
168+
"vanity-123",
169+
"/pre-cached-url",
170+
defaultLanguage.getId(),
171+
defaultHost.getIdentifier(),
172+
"/forwarded-destination",
173+
301,
174+
1
175+
);
176+
177+
final var context = DotGraphQLContext.createServletContext()
178+
.with(APILocator.systemUser())
179+
.build();
180+
context.addParam("cachedVanityUrl", preCachedVanityUrl);
181+
182+
Mockito.when(environment.getContext()).thenReturn(context);
183+
Mockito.when(environment.getSource()).thenReturn(TestDataUtils.getPageContent(true, defaultLanguage.getId()));
184+
185+
final var cachedVanityUrl = fetcher.get(environment);
186+
assertNotNull(cachedVanityUrl);
187+
assertEquals("/pre-cached-url", cachedVanityUrl.url);
188+
assertEquals("/forwarded-destination", cachedVanityUrl.forwardTo);
189+
assertEquals(301, cachedVanityUrl.response);
190+
}
156191
}

0 commit comments

Comments
 (0)