Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an initial revision of a semantic vanity url servlet #529

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.redhat.pantheon.servlet;

import com.google.common.base.Charsets;
import com.redhat.pantheon.extension.url.CustomerPortalUrlUuidProvider;
import com.redhat.pantheon.html.Html;
import com.redhat.pantheon.model.ProductVersion;
import com.redhat.pantheon.model.api.FileResource;
import com.redhat.pantheon.model.module.ModuleMetadata;
import com.redhat.pantheon.model.module.ModuleVariant;
import com.redhat.pantheon.model.module.ModuleVersion;
import com.redhat.pantheon.servlet.util.ServletHelper;
import org.apache.sling.api.SlingHttpServletRequest;

import javax.jcr.RepositoryException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static com.google.common.collect.Maps.newHashMap;
import static com.redhat.pantheon.conf.GlobalConfig.CONTENT_TYPE;
import static javax.servlet.http.HttpServletResponse.SC_OK;

/**
* A series of converters to map form for different business purposes.
*/
public class MapConverters {

private MapConverters() {
}

/**
* Converts a {@link ModuleVariant} object to a map for returning in API calls.
* @param request The web request being processed.
* @param mv The module variant domain object to transform to a map.
* @return A map for Json conversion in API calls with the module variant's information.
* @throws RepositoryException IF there is a problem fetching related data when building the map.
*/
public static final Map<String, Object> moduleVariantToMap(final SlingHttpServletRequest request,
final ModuleVariant mv)
throws RepositoryException {
Optional<ModuleMetadata> releasedMetadata = mv.released()
.toChild(ModuleVersion::metadata)
.asOptional();
Optional<FileResource> releasedContent = mv.released()
.toChild(ModuleVersion::cachedHtml)
.asOptional();
Optional<ModuleVersion> releasedRevision = mv.released()
.asOptional();

Map<String, Object> variantMap = newHashMap(mv.getValueMap());
Map<String, Object> variantDetails = new HashMap<>();

variantDetails.put("status", SC_OK);
variantDetails.put("message", "Module Found");

String resourcePath = mv.getPath();
variantMap.put("locale", ServletUtils.toLanguageTag(mv.getParentLocale().getName()));
variantMap.put("revision_id", releasedRevision.get().getName());
variantMap.put("title", releasedMetadata.get().title().get());
variantMap.put("headline", releasedMetadata.get().getValueMap().containsKey("pant:headline") ? releasedMetadata.get().headline().get() : "");
variantMap.put("description", releasedMetadata.get().getValueMap().containsKey("jcr:description") ? releasedMetadata.get().description().get() : releasedMetadata.get().mAbstract().get());
variantMap.put("content_type", CONTENT_TYPE);
variantMap.put("date_published", releasedMetadata.get().getValueMap().containsKey("pant:datePublished") ? releasedMetadata.get().datePublished().get().toInstant().toString() : "");
variantMap.put("date_first_published", releasedMetadata.get().getValueMap().containsKey("pant:dateFirstPublished") ? releasedMetadata.get().dateFirstPublished().get().toInstant().toString() : "");
variantMap.put("status", "published");

// Assume the path is something like: /content/<something>/my/resource/path
variantMap.put("module_url_fragment", resourcePath.substring("/content/repositories/".length()));

// Striping out the jcr: from key name
String variant_uuid = (String) variantMap.remove("jcr:uuid");
// TODO: remove uuid when there are no more consumers for it (Solr, Hydra, Customer Portal)
variantMap.put("uuid", variant_uuid);
variantMap.put("variant_uuid", variant_uuid);
variantMap.put("document_uuid", mv.getParentLocale().getParent().uuid().get());
// Convert date string to UTC
Date dateModified = new Date(mv.getResourceMetadata().getModificationTime());
variantMap.put("date_modified", dateModified.toInstant().toString());
// Return the body content of the module ONLY
variantMap.put("body",
Html.parse(Charsets.UTF_8.name())
.andThen(Html.rewriteUuidUrls(request.getResourceResolver(), new CustomerPortalUrlUuidProvider()))
.andThen(Html.getBody())
.apply(releasedContent.get().jcrContent().get().jcrData().get()));

// Fields that are part of the spec and yet to be implemented
// TODO Should either of these be the variant name?
variantMap.put("context_url_fragment", "");
variantMap.put("context_id", "");

// Process productVersion from metadata
// Making these arrays - in the future, we will have multi-product, so get the API right the first time
List<Map> productList = new ArrayList<>();
variantMap.put("products", productList);
ProductVersion pv = releasedMetadata.get().productVersion().getReference();
String versionUrlFragment = "";
String productUrlFragment = "";
if (pv != null) {
Map<String, String> productMap = new HashMap<>();
productList.add(productMap);
productMap.put("product_version", pv.name().get());
versionUrlFragment = pv.getValueMap().containsKey("urlFragment") ? pv.urlFragment().get() : "";
productMap.put("version_url_fragment", versionUrlFragment);
productUrlFragment = pv.getProduct().getValueMap().containsKey("urlFragment") ? pv.getProduct().urlFragment().get() : "";
productMap.put("product_name", pv.getProduct().name().get());
productMap.put("product_url_fragment", productUrlFragment);
}

// Process url_fragment from metadata
String urlFragment = releasedMetadata.get().urlFragment().get() != null ? releasedMetadata.get().urlFragment().get() : "";
if (!urlFragment.isEmpty()) {
variantMap.put("vanity_url_fragment", urlFragment);
} else {
variantMap.put("vanity_url_fragment", "");
}

String searchKeywords = releasedMetadata.get().searchKeywords().get();
if (searchKeywords != null && !searchKeywords.isEmpty()) {
variantMap.put("search_keywords", searchKeywords.split(", *"));
} else {
variantMap.put("search_keywords", new String[]{});
}

// Process view_uri
if (System.getenv("portal_url") != null) {
String view_uri = new CustomerPortalUrlUuidProvider().generateUrlString(mv);
variantMap.put("view_uri", view_uri);
} else {
variantMap.put("view_uri", "");
}
List<HashMap<String, String>> includeAssemblies = new ArrayList<>();

//get the assemblies and iterate over them

ServletHelper.addAssemblyDetails(ServletHelper.getModuleUuidFromVariant(mv), includeAssemblies, request, false, false);
variantMap.put("included_in_guides", includeAssemblies);
variantMap.put("isPartOf", includeAssemblies);
// remove unnecessary fields from the map
variantMap.remove("jcr:lastModified");
variantMap.remove("jcr:lastModifiedBy");
variantMap.remove("jcr:createdBy");
variantMap.remove("jcr:created");
variantMap.remove("sling:resourceType");
variantMap.remove("jcr:primaryType");

// Adding variantMap to a parent variantDetails map
variantDetails.put("module", variantMap);

return variantDetails;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.redhat.pantheon.servlet.module;

import com.ibm.icu.util.ULocale;
import com.redhat.pantheon.jcr.JcrQueryHelper;
import com.redhat.pantheon.model.api.SlingModels;
import com.redhat.pantheon.model.module.ModuleVariant;
import com.redhat.pantheon.model.module.ModuleVersion;
import com.redhat.pantheon.servlet.MapConverters;
import com.redhat.pantheon.servlet.util.SlingPathSuffix;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.apache.sling.servlets.annotations.SlingServletPaths;
import org.jetbrains.annotations.NotNull;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Component;

import javax.jcr.RepositoryException;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;

import static com.redhat.pantheon.servlet.ServletUtils.writeAsJson;

/**
* An API endpoint for finding module information using a vanity url format which takes into account the content's
* product metadata, and locale. The format is as follows:<br><br>
*
* '/api/module/vanity.json/{locale}/{product}/{version}/{vanityUrlFragment}'<br><br>
*
* It will also resolve content with the following format: <br><br>
*
* '/api/module/vanity.json/{locale}/{product}/{version}/{moduleUUID}'
*
* <br>
* @see VariantJsonServlet for a servlet returning the same information
* @author Carlos Munoz
*/
@Component(
service = Servlet.class,
property = {
Constants.SERVICE_DESCRIPTION + "=Servlet to facilitate GET operation which accepts several path parameters to fetch module variant data",
Constants.SERVICE_VENDOR + "=Red Hat Content Tooling team"
})
@SlingServletPaths(value = "/api/module/vanity")
Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting that you register this servlet by path. I'm wondering if that doesn't overburden Drupal... Drupal would have to be smart enough to know whether the URL it was handling was a vanity URL or not and then call the correct Pantheon endpoint accordingly. That might be tough. I wonder if it doesn't make more sense for us to try and implement something more like a DefaultGetServlet for Sling that could handle the whole spectrum of requests and internally forward them to servlets appropriately.

// /api/module/vanity.json/{locale}/{productLabel}/{versionLabel}/{vanityUrl}
public class VanityVariantJsonServlet extends SlingSafeMethodsServlet {

private final SlingPathSuffix suffix = new SlingPathSuffix("/{locale}/{productLabel}/{versionLabel}/{vanityUrl}");
Copy link
Collaborator

Choose a reason for hiding this comment

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

the customer portal view_uri is constructed with product urlFragment and version urlFragment. not labels. Should we follow the same convention and use productUrlFragment and versionUrlFragment?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed. If you look at the code it is in fact filtering by url fragment. The strings are just pattern placeholders to be able to extract the right values from the url. I will change them to reflect that they are url fragments and not label.


@Override
protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) throws ServletException, IOException {
Map<String, String> params = suffix.getParameters(request);
String locale = params.get("locale");
String productLabel = params.get("productLabel");
String versionLabel = params.get("versionLabel");
String vanityUrl = params.get("vanityUrl");

JcrQueryHelper queryHelper = new JcrQueryHelper(request.getResourceResolver());
try {

// Find the metadata with the right vanity url,
Optional<ModuleVersion> moduleVersion = queryHelper.query(
"select * from [pant:moduleVersion] as v where v.[metadata/urlFragment] = '/" + vanityUrl + "'")
Copy link
Contributor

Choose a reason for hiding this comment

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

A couple thoughts...

  • Why are you using Module classes instead of Document classes (then it would hopefully work for Modules and Assemblies)?
  • I see you're querying for the vanity url at the location we keep it today, which is on the metadata node. That does work, but I think I'd like to see a system where we manage vanity URLs (and redirects, etc) as separate entities in the system (perhaps located at /content/redirects in the JCR, something along those lines?) that contain pointers to the UUIDs of the DocumentVariants (would need to nail down what makes the most sense there) that they redirect to. In that world, this query would obviously need to be refactored. Regardless, the overall concept here is still solid.

.map(resource -> SlingModels.getModel(resource, ModuleVersion.class))
// the right locale,
.filter(modVer -> {
String normalizedLocaleCode = ULocale.canonicalize(locale);
String normalizedModuleLocaleCode = ULocale.canonicalize(modVer.getParent().getParentLocale().getName());
return normalizedLocaleCode.equals(normalizedModuleLocaleCode);
})
// the right version,
.filter(modVer -> {
try {
return modVer.metadata().get().productVersion().getReference().name().get().equals(versionLabel);
} catch (RepositoryException e) {
throw new RuntimeException(e);
}
})
// and the right product
.filter(modVer -> {
try {
return modVer.metadata().get().productVersion().getReference().getProduct().urlFragment().get().equals(productLabel);
} catch (RepositoryException e) {
throw new RuntimeException(e);
}
})
// There should be 1 at most, but get the first if there are more
.findFirst();
Copy link
Contributor

Choose a reason for hiding this comment

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

In today's world, there could definitely be more than one. There's nothing stopping me from adding a vanity URL of "/cats" to every document in the system. My idea above of managing redirects as separate entities neatly addresses this problem.


if(!moduleVersion.isPresent()) {
// try to find by variant uuid, instead of vanity url
moduleVersion = findModuleByUuid(queryHelper, productLabel, versionLabel, locale, vanityUrl);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering why this "vanity" servlet is also accepting the UUID - perhaps you envision this new servlet as "the" one-and-only servlet that Drupal would call, and it handles anything you throw at it? Can you clarify if that's what you intended?

}

if(!moduleVersion.isPresent()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Module version with vanity url '" + vanityUrl + "' not found");
return;
}
else {
// TODO This is traversing up to the variant to keep the compatibility with VariantJsonServlet
// it should be revisited if/after this api is deprecated
writeAsJson(response, MapConverters.moduleVariantToMap(request, moduleVersion.get().getParent()));
}

} catch (RepositoryException e) {
throw new RuntimeException(e);
}
}

// TODO This could just piggy back on the other variant json servlet (but then there is no validation)
private Optional<ModuleVersion> findModuleByUuid(final JcrQueryHelper queryHelper,
final String productUrlFragment,
final String productVersionUrlFragment,
final String locale,
final String moduleVariantUuid) {
try {
return queryHelper.query("select * from [pant:moduleVariant] as moduleVariant WHERE moduleVariant.[jcr:uuid] = '" +
moduleVariantUuid + "'")
.map(resource -> SlingModels.getModel(resource, ModuleVariant.class))
// check the other parameters match
.filter(moduleVariant -> {
try {
return ULocale.canonicalize(moduleVariant.getParentLocale().getName()).equals(ULocale.canonicalize(locale))
&& moduleVariant.released().get().metadata().get().productVersion().getReference().urlFragment().get()
.equals(productVersionUrlFragment)
&& moduleVariant.released().get().metadata().get().productVersion().getReference().getProduct()
.urlFragment().get().equals(productUrlFragment);
} catch (RepositoryException e) {
throw new RuntimeException(e);
}
})
// use the released version to keep compatibility with VariantJsonServlet
.map(moduleVariant -> moduleVariant.released().get())
.findFirst();
} catch (RepositoryException e) {
throw new RuntimeException(e);
}
}
}