Java tutorial
/* * Copyright (c) 2017 Public Library of Science * * Permission is hereby granted, free of charge, to any person obtaining a * copy of this software and associated documentation files (the "Software"), * to deal in the Software without restriction, including without limitation * the rights to use, copy, modify, merge, publish, distribute, sublicense, * and/or sell copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER * DEALINGS IN THE SOFTWARE. */ package org.ambraproject.wombat.controller; import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.LinkedListMultimap; import com.google.common.collect.Maps; import com.google.common.collect.Multimap; import org.ambraproject.wombat.config.site.RequestMappingContextDictionary; import org.ambraproject.wombat.config.site.Site; import org.ambraproject.wombat.config.site.SiteSet; import org.ambraproject.wombat.config.site.url.Link; import org.ambraproject.wombat.identity.ArticlePointer; import org.ambraproject.wombat.identity.RequestedDoiVersion; import org.ambraproject.wombat.model.ArticleType; import org.ambraproject.wombat.service.ArticleResolutionService; import org.ambraproject.wombat.service.ArticleService; import org.ambraproject.wombat.service.ArticleTransformService; import org.ambraproject.wombat.service.EntityNotFoundException; import org.ambraproject.wombat.service.PeerReviewService; import org.ambraproject.wombat.service.XmlUtil; import org.ambraproject.wombat.service.remote.ApiAddress; import org.ambraproject.wombat.service.remote.ArticleApi; import org.ambraproject.wombat.service.remote.CorpusContentApi; import org.ambraproject.wombat.util.TextUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.ui.Model; import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.io.InputStream; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; import java.util.stream.Collectors; public class ArticleMetadata { private static final Logger log = LoggerFactory.getLogger(ArticleMetadata.class); private final Factory factory; // for further service access private final Site site; private final RequestedDoiVersion articleId; private final ArticlePointer articlePointer; private final Map<String, ?> ingestionMetadata; private final Map<String, ?> itemTable; private final Map<String, List<Map<String, ?>>> relationships; private ArticleMetadata(Factory factory, Site site, RequestedDoiVersion articleId, ArticlePointer articlePointer, Map<String, ?> ingestionMetadata, Map<String, ?> itemTable, Map<String, List<Map<String, ?>>> relationships) { this.factory = Objects.requireNonNull(factory); this.site = Objects.requireNonNull(site); this.articleId = Objects.requireNonNull(articleId); this.articlePointer = Objects.requireNonNull(articlePointer); this.ingestionMetadata = Collections.unmodifiableMap(ingestionMetadata); this.itemTable = Collections.unmodifiableMap(itemTable); this.relationships = Collections.unmodifiableMap(relationships); } /** * Constructor for the purpose of running unit tests. */ ArticleMetadata() { factory = null; site = null; articleId = null; articlePointer = null; ingestionMetadata = null; itemTable = null; relationships = null; } public static class Factory { @Autowired private ArticleApi articleApi; @Autowired private CorpusContentApi corpusContentApi; @Autowired private ArticleService articleService; @Autowired private ArticleResolutionService articleResolutionService; @Autowired private SiteSet siteSet; @Autowired private ArticleTransformService articleTransformService; @Autowired private RequestMappingContextDictionary requestMappingContextDictionary; @Autowired private PeerReviewService peerReviewService; public ArticleMetadata get(Site site, RequestedDoiVersion id) throws IOException { return get(site, id, articleResolutionService.toIngestion(id)); } public ArticleMetadata get(Site site, RequestedDoiVersion id, ArticlePointer articlePointer) throws IOException { Map<String, Object> ingestionMetadata; try { ingestionMetadata = (Map<String, Object>) articleApi .requestObject(articlePointer.asApiAddress().build(), Map.class); } catch (EntityNotFoundException e) { throw new NotFoundException(e); } Map<String, ?> itemTable = articleService.getItemTable(articlePointer); ApiAddress relationshipsApiAddress = ApiAddress.builder("articles").embedDoi(articlePointer.getDoi()) .addToken("relationships").build(); Map<String, List<Map<String, ?>>> relationships = articleApi.requestObject(relationshipsApiAddress, Map.class); final ArticleMetadata articleMetaData = newInstance(site, id, articlePointer, ingestionMetadata, itemTable, relationships); return articleMetaData; } /** * Creates an instance of {@link ArticleMetadata}. * * @param site * @param articleId * @param articlePointer * @param ingestionMetadata * @param itemTable * @param relationships * @return The article metadata */ public ArticleMetadata newInstance(Site site, RequestedDoiVersion articleId, ArticlePointer articlePointer, Map<String, ?> ingestionMetadata, Map<String, ?> itemTable, Map<String, List<Map<String, ?>>> relationships) { final ArticleMetadata articleMetaData = new ArticleMetadata(this, site, articleId, articlePointer, ingestionMetadata, itemTable, relationships); return articleMetaData; } } public ArticlePointer getArticlePointer() { return articlePointer; } public Map<String, ?> getIngestionMetadata() { return ingestionMetadata; } public ArticleMetadata populate(HttpServletRequest request, Model model) throws IOException { model.addAttribute("versionPtr", articlePointer.getVersionParameter()); model.addAttribute("articlePtr", articlePointer.asParameterMap()); model.addAttribute("article", ingestionMetadata); model.addAttribute("articleItems", itemTable); model.addAttribute("figures", getFigureView()); model.addAttribute("articleType", getArticleType()); model.addAttribute("commentCount", getCommentCount()); model.addAttribute("containingLists", getContainingArticleLists()); model.addAttribute("categoryTerms", getCategoryTerms()); model.addAttribute("relatedArticles", getRelatedArticles()); populateAuthors(model); model.addAttribute("revisionMenu", getRevisionMenu()); model.addAttribute("peerReview", getPeerReviewHtml()); return this; } public static class RevisionMenu { private final ImmutableList<Map<String, ?>> revisions; private final boolean isDisplayingLatestRevision; private RevisionMenu(Collection<Map<String, ?>> revisions, boolean isDisplayingLatestRevision) { this.revisions = ImmutableList.copyOf(revisions); this.isDisplayingLatestRevision = isDisplayingLatestRevision; } public ImmutableList<Map<String, ?>> getRevisions() { return revisions; } // Named for FreeMarker public boolean getIsDisplayingLatestRevision() { return isDisplayingLatestRevision; } } private static int getRevisionNumber(Map<String, ?> revisionMetadata) { return ((Number) revisionMetadata.get("revisionNumber")).intValue(); } RevisionMenu getRevisionMenu() throws IOException { List<Map<String, ?>> revisionList = factory.articleApi.requestObject( ApiAddress.builder("articles").embedDoi(articleId.getDoi()).addToken("revisions").build(), List.class); OptionalInt displayedNumber = articlePointer.getRevisionNumber(); revisionList = revisionList.stream().map((Map<String, ?> revision) -> { boolean isDisplayedRevision = displayedNumber.isPresent() && getRevisionNumber(revision) == displayedNumber.getAsInt(); return ImmutableMap.<String, Object>builder().putAll(revision).put("isDisplayed", isDisplayedRevision) .build(); }).sorted(Comparator.comparing(ArticleMetadata::getRevisionNumber)).collect(Collectors.toList()); boolean isDisplayingLatestRevision = !revisionList.isEmpty() && (Boolean) Iterables.getLast(revisionList).get("isDisplayed"); return new RevisionMenu(revisionList, isDisplayingLatestRevision); } /** * Get peer review as an HTML snippet. */ String getPeerReviewHtml() throws IOException { return factory.peerReviewService.asHtml(itemTable); } /** * Validate that an article ought to be visible to the user. If not, throw an exception indicating that the user * should see a 404. * <p/> * An article may be invisible if it is not in a published state, or if it has not been published in a journal * corresponding to the site. * * @throws NotVisibleException if the article is not visible on the site */ public ArticleMetadata validateVisibility(String handlerName) { Map<String, ?> journal = (Map<String, ?>) ingestionMetadata.get("journal"); String publishedJournalKey = (String) journal.get("journalKey"); String siteJournalKey = site.getJournalKey(); if (!publishedJournalKey.equals(siteJournalKey)) { Link link = buildCrossSiteRedirect(publishedJournalKey, handlerName); throw new InternalRedirectException(link); } return this; } Link buildCrossSiteRedirect(String targetJournal, String handlerName) { Site targetSite = this.site.getTheme().resolveForeignJournalKey(factory.siteSet, targetJournal); return Link.toForeignSite(site, targetSite).toPattern(factory.requestMappingContextDictionary, handlerName) .addQueryParameters(articlePointer.asParameterMap()).build(); } private static final ImmutableSet<String> FIGURE_TYPES = ImmutableSet.of("figure", "table"); /* * Build a view of the article's figures and tables, with the following properties that are significant for display: * * (1) The figure DOIs are listed in the same order in which they appear in the original manuscript and should be * displayed to the user (in a table of contents, figure carousel, etc.). Compare to the item table, which has * no order. * * (2) Only items of the type "figure" or "table" are included. It excludes other items such as the manuscript, * the PDF file, supplementary material, inline graphics, and striking images. */ public List<Map<String, ?>> getFigureView() { List<Map<String, ?>> assetsLinkedFromManuscript = (List<Map<String, ?>>) ingestionMetadata .get("assetsLinkedFromManuscript"); return assetsLinkedFromManuscript.stream().map((Map<String, ?> asset) -> { String assetDoi = (String) asset.get("doi"); Map<String, ?> item = (Map<String, ?>) itemTable.get(assetDoi); if (item == null) { log.error(String.format("Asset %s is referenced in the manuscript but absent from the database.", assetDoi)); return null; // log error for any missing assets, but don't block article rendering } String type = (String) item.get("itemType"); if (!FIGURE_TYPES.contains(type)) return null; // filter out non-figure assets Map<String, Object> view = new HashMap<>(asset); view.put("type", type); return view; }).filter(Objects::nonNull).collect(Collectors.toList()); } ArticleType getArticleType() { String typeName = (String) ingestionMetadata.get("articleType"); return ArticleType.getDictionary(site.getTheme()).lookUp(typeName); } Map<String, Integer> getCommentCount() throws IOException { return factory.articleApi.requestObject(ApiAddress.builder("articles").embedDoi(articleId.getDoi()) .addToken("comments").addParameter("count").build(), Map.class); } Map<String, Collection<Object>> getContainingArticleLists() throws IOException { List<Map<?, ?>> articleListObjects = factory.articleApi.requestObject( ApiAddress.builder("articles").embedDoi(articleId.getDoi()).addParameter("lists").build(), List.class); Multimap<String, Object> result = LinkedListMultimap.create(articleListObjects.size()); for (Map<?, ?> articleListObject : articleListObjects) { String listType = Preconditions.checkNotNull((String) articleListObject.get("type")); result.put(listType, articleListObject); } return result.asMap(); } private static class Category { private final String path; private final String term; private final int weight; private Category(Map<String, ?> categoryData) { this.path = Objects.requireNonNull((String) categoryData.get("path")); this.term = getCategoryTermFromPath(path); this.weight = ((Number) categoryData.get("weight")).intValue(); } private static final Splitter CATEGORY_SPLITTER = Splitter.on('/').omitEmptyStrings(); private static String getCategoryTermFromPath(String path) { return Iterables.getLast(CATEGORY_SPLITTER.split(path)); } private String getTerm() { return term; } private int getWeight() { return weight; } } /** * Iterate over article categories and extract and sort unique category terms (i.e., the final category term in a * given category path) * * @return a sorted list of category terms */ List<String> getCategoryTerms() throws IOException { List<Map<String, ?>> categoryViews = (List<Map<String, ?>>) factory.articleApi.requestObject( ApiAddress.builder("articles").embedDoi(articleId.getDoi()).addToken("categories").build(), List.class); // Remove duplicate paths that have the same term. Map<String, Category> categoryMap = Maps.newHashMapWithExpectedSize(categoryViews.size()); for (Map<String, ?> categoryView : categoryViews) { Category category = new Category(categoryView); Category previous = categoryMap.put(category.term, category); if (previous != null) { // They should differ only by path. if (category.weight != previous.weight) { log.warn(String.format( "In category assignments for %s, inconsistent weights for same term. \"%s\": %d; \"%s\": %d", articlePointer.getDoi(), category.path, category.weight, previous.path, previous.weight)); } // else, it's okay for it to be replaced because the term and weight are the same } } // Sort by descending weight, then alphabetically by term return categoryMap.values().stream() .sorted(Comparator.comparing(Category::getWeight).reversed().thenComparing(Category::getTerm)) .map(Category::getTerm).collect(Collectors.toList()); } private static boolean isPublished(Map<String, ?> relatedArticle) { return relatedArticle.get("revisionNumber") != null; } private static final Comparator<Map<String, ?>> BY_DESCENDING_PUB_DATE = Comparator.comparing( (Map<String, ?> articleMetadata) -> LocalDate.parse((String) articleMetadata.get("publicationDate"))) .reversed(); private static final ImmutableSet<String> RELATIONSHIP_DIRECTIONS = ImmutableSet.of("inbound", "outbound"); List<Map<String, ?>> getRelatedArticles() { // Eliminate duplicate DOIs (in case there are inbound and outbound relationships with the same article) Map<String, Map<String, ?>> relationshipsByDoi = new HashMap<>(); for (String direction : RELATIONSHIP_DIRECTIONS) { for (Map<String, ?> relatedArticle : relationships.get(direction)) { String relatedArticleDoi = (String) relatedArticle.get("doi"); Map<String, ?> previous = relationshipsByDoi.put(relatedArticleDoi, relatedArticle); if (previous != null) { // Collisions are okay if a relationship exists in each direction. Verify that the data are consistent. Preconditions.checkState( Objects.equals(relatedArticle.get("revisionNumber"), previous.get("revisionNumber")) && Objects.equals(relatedArticle.get("title"), previous.get("title")) && Objects.equals(relatedArticle.get("publicationDate"), previous.get("publicationDate"))); // It is fine for relatedArticle.get("type") and previous.get("type") to be unequal. } } } return relationshipsByDoi.values().stream().filter(ArticleMetadata::isPublished) .sorted(BY_DESCENDING_PUB_DATE).collect(Collectors.toList()); } public Map<String, ?> getAuthors() throws IOException { ApiAddress authorAddress = articlePointer.asApiAddress().addToken("authors").build(); return factory.articleApi.requestObject(authorAddress, Map.class); } /** * Appends additional info about article authors to the model. * * @param model model to be passed to the view * @return the list of authors appended to the model * @throws IOException */ void populateAuthors(Model model) throws IOException { Map<?, ?> allAuthorsData = getAuthors(); List<?> authors = (List<?>) allAuthorsData.get("authors"); model.addAttribute("authors", authors); // Putting this here was a judgement call. One could make the argument that this logic belongs // in Rhino, but it's so simple I elected to keep it here for now. List<String> equalContributors = new ArrayList<>(); for (Object o : authors) { Map<String, Object> author = (Map<String, Object>) o; String fullName = (String) author.get("fullName"); Object obj = author.get("equalContrib"); if (obj != null && (boolean) obj) { equalContributors.add(fullName); } // remove the footnote marker from the current address List<String> currentAddresses = (List<String>) author.get("currentAddresses"); for (ListIterator<String> iterator = currentAddresses.listIterator(); iterator.hasNext();) { String currentAddress = iterator.next(); iterator.set(TextUtil.removeFootnoteMarker(currentAddress)); } } model.addAttribute("authorContributions", allAuthorsData.get("authorContributions")); model.addAttribute("competingInterests", allAuthorsData.get("competingInterests")); model.addAttribute("correspondingAuthors", allAuthorsData.get("correspondingAuthorList")); model.addAttribute("equalContributors", equalContributors); } /** * Check related articles for ones that amend this article. Set them up for special display, and retrieve additional * data about those articles from the service tier. */ public ArticleMetadata fillAmendments(Model model) throws IOException { List<Map<String, ?>> inboundRelationships = relationships.get("inbound"); List<Map<String, Object>> amendments = inboundRelationships.parallelStream() .filter((Map<String, ?> relatedArticle) -> isPublished(relatedArticle) && getAmendmentType(relatedArticle).isPresent()) .map((Map<String, ?> relatedArticle) -> createAmendment(site, relatedArticle)) .sorted(BY_DESCENDING_PUB_DATE).collect(Collectors.toList()); List<AmendmentGroup> amendmentGroups = buildAmendmentGroups(amendments); model.addAttribute("amendments", amendmentGroups); return this; } /** * Types of related articles that get special display handling. */ private static enum AmendmentType { CORRECTION("corrected-article"), EOC("object-of-concern"), RETRACTION("retracted-article"); /** * A value of the "type" field of an object in the list of inbound relationships. */ private final String relationshipType; private AmendmentType(String relationshipType) { this.relationshipType = relationshipType; } // For use as a key in maps destined for the FreeMarker model private String getLabel() { return name().toLowerCase(); } private static final ImmutableMap<String, AmendmentType> BY_RELATIONSHIP_TYPE = Maps .uniqueIndex(EnumSet.allOf(AmendmentType.class), input -> input.relationshipType); } /** * @return the amendment type of the relationship, or empty if the relationship is not an amendment */ private Optional<AmendmentType> getAmendmentType(Map<String, ?> relatedArticle) { String relationshipType = (String) relatedArticle.get("type"); AmendmentType amendmentType = AmendmentType.BY_RELATIONSHIP_TYPE.get(relationshipType); return Optional.ofNullable(amendmentType); } /** * @param site the site being rendered * @param relatedArticle a relationship to an amendment to this article * @return a model of the amendment * @throws IllegalArgumentException if the relationship is not of an amendment type */ private Map<String, Object> createAmendment(Site site, Map<String, ?> relatedArticle) { AmendmentType amendmentType = getAmendmentType(relatedArticle).orElseThrow(IllegalArgumentException::new); String doi = (String) relatedArticle.get("doi"); ArticlePointer amendmentId; Map<String, Object> amendment; Map<String, ?> authors; try { amendmentId = factory.articleResolutionService.toIngestion(RequestedDoiVersion.of(doi)); // always uses latest revision amendment = (Map<String, Object>) factory.articleApi.requestObject(amendmentId.asApiAddress().build(), Map.class); authors = factory.articleApi.requestObject(amendmentId.asApiAddress().addToken("authors").build(), Map.class); } catch (IOException e) { throw new RuntimeException(e); } amendment.putAll(authors); // Display the body only on non-correction amendments. Would be better if this were configurable per theme. if (amendmentType != AmendmentType.CORRECTION) { String body; try { body = getAmendmentBody(amendmentId); } catch (IOException e) { throw new RuntimeException("Could not get body for amendment: " + doi, e); } amendment.put("body", body); } amendment.put("type", amendmentType.getLabel()); return amendment; } /** * Combine adjacent amendments that have the same type into one AmendmentGroup object, for display purposes. If * multiple amendments share a type but are separated in order by a different type, they go in separate groups. * * @param amendments a list of amendment objects in their desired display order * @return the amendments grouped by type in the same order */ private static List<AmendmentGroup> buildAmendmentGroups(List<Map<String, Object>> amendments) { if (amendments.isEmpty()) return ImmutableList.of(); List<AmendmentGroup> amendmentGroups = new ArrayList<>(amendments.size()); List<Map<String, Object>> nextGroup = null; String type = null; for (Map<String, Object> amendment : amendments) { String nextType = (String) amendment.get("type"); if (nextGroup == null || !Objects.equals(type, nextType)) { if (nextGroup != null) { amendmentGroups.add(new AmendmentGroup(type, nextGroup)); } type = nextType; nextGroup = new ArrayList<>(); } nextGroup.add(amendment); } amendmentGroups.add(new AmendmentGroup(type, nextGroup)); return amendmentGroups; } public static class AmendmentGroup { private final String type; private final ImmutableList<Map<String, Object>> amendments; private AmendmentGroup(String type, List<Map<String, Object>> amendments) { this.type = Objects.requireNonNull(type); this.amendments = ImmutableList.copyOf(amendments); Preconditions.checkArgument(!this.amendments.isEmpty()); } public String getType() { return type; } public ImmutableList<Map<String, Object>> getAmendments() { return amendments; } } /** * Retrieve and transform the body of an amendment article from its XML file. The returned value is cached. * * @return the body of the amendment article, transformed into HTML for display in a notice on the amended article */ private String getAmendmentBody(ArticlePointer amendmentId) throws IOException { return factory.corpusContentApi.readManuscript(amendmentId, site, "amendmentBody", (InputStream stream) -> { // Extract the "/article/body" element from the amendment XML, not to be confused with the HTML <body> element. String bodyXml = XmlUtil.extractElement(stream, "body"); return factory.articleTransformService.transformAmendmentBody(site, amendmentId, bodyXml); }); } }