View Javadoc
1   /**
2    * This file Copyright (c) 2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.periscope.search.rest;
35  
36  import static com.google.common.base.Charsets.UTF_8;
37  
38  import info.magnolia.periscope.operation.request.ExternalNavigationRequest;
39  import info.magnolia.periscope.search.SearchException;
40  import info.magnolia.periscope.search.SearchQuery;
41  import info.magnolia.periscope.search.SearchResult;
42  import info.magnolia.periscope.search.SearchResultSupplier;
43  
44  import java.io.UnsupportedEncodingException;
45  import java.net.URLEncoder;
46  import java.time.ZonedDateTime;
47  import java.time.format.DateTimeFormatter;
48  import java.time.format.DateTimeParseException;
49  import java.util.Collections;
50  import java.util.List;
51  import java.util.Objects;
52  import java.util.StringJoiner;
53  import java.util.stream.Stream;
54  
55  import javax.ws.rs.client.Client;
56  import javax.ws.rs.client.ClientBuilder;
57  import javax.ws.rs.client.WebTarget;
58  import javax.ws.rs.core.MediaType;
59  
60  import org.apache.commons.collections4.CollectionUtils;
61  import org.apache.commons.lang3.StringUtils;
62  import org.jsoup.Jsoup;
63  import org.jsoup.parser.Parser;
64  import org.jsoup.safety.Whitelist;
65  import org.slf4j.Logger;
66  import org.slf4j.LoggerFactory;
67  
68  import com.jayway.jsonpath.JsonPath;
69  import com.jayway.jsonpath.ReadContext;
70  
71  /**
72   * A REST based {@link SearchResultSupplier Search result supplier} which is responsible make the REST call itself.
73   *
74   * <p>
75   *     Internally uses JsonPath notation to determine which JSON key-value to search.
76   *     Those are defined in {@link RestSearchResultSupplierDefinition}.
77   * </p>
78   *
79   *  @deprecated since 1.2 - use {@info.magnolia.rest.ui.periscope.RestClientResultSupplier} in magnolia-rest-client-ui instead
80   */
81  @Deprecated
82  public class RestSearchResultSupplier implements SearchResultSupplier {
83  
84      private static final Logger log = LoggerFactory.getLogger(RestSearchResultSupplier.class);
85  
86      private final String name;
87      private final RestSearchResultSupplierDefinition definition;
88      private final String baseUrl;
89      private final String requestParameters;
90      private final Client client;
91  
92      RestSearchResultSupplier(String name, RestSearchResultSupplierDefinition definition) {
93          this.name = name;
94          this.definition = definition;
95          this.baseUrl = definition.getBaseUrl();
96  
97          // because REST queries URL's make heavy use of chars such as '=', '?' and '&'
98          // we need to unescape entities, after they have been whitelisted
99          this.requestParameters = definition.getRequestParameters() != null ?
100                 Parser.unescapeEntities(Jsoup.clean(definition.getRequestParameters(), Whitelist.basic()), false) :
101                 "";
102 
103         Client restClient;
104         try {
105             restClient = ClientBuilder.newClient();
106         } catch (RuntimeException e) {
107             log.error("REST Client couldn't be built, is there any JAX-RS client impl on the classpath?", e);
108             restClient = null;
109         }
110         this.client = restClient;
111     }
112 
113     @Override
114     public String getName() {
115         return this.name;
116     }
117 
118     @Override
119     public Stream<SearchResult> search(SearchQuery query) throws SearchException {
120         sanityCheck();
121 
122         if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(definition.getNavigationBaseURL())
123                 || StringUtils.isBlank(definition.getTitleJsonPath()) || StringUtils.isBlank(definition.getNavigationURLJsonPath())) {
124             throw new SearchException(query.getQuery(),
125                     new IllegalArgumentException("RestSearchResultSupplierDefinition is not configured correctly."));
126         }
127 
128         try {
129             String targetUrl = constructTargetUrl(query);
130             WebTarget target = client.target(targetUrl);
131             String resultJsonString = target.request(MediaType.APPLICATION_JSON_TYPE).get(String.class);
132 
133             return createResults(resultJsonString);
134         } catch (UnsupportedEncodingException e) {
135             throw new SearchException(query.getQuery(), e);
136         }
137     }
138 
139     /**
140      * Checks if the {@link SearchResultSupplier} is capable of proceeding with the request.
141      *
142      * @throws IllegalStateException if either the baseUrl is empty or the REST {@link Client} couldn't be built.
143      */
144     private void sanityCheck() {
145         if (baseUrl.isEmpty()) {
146             throw new IllegalStateException(String.format("baseUrl is empty, check logs for '%s' resultSupplier initialization problems.", this.name));
147         } else if (client == null) {
148             throw new IllegalStateException(String.format("REST Client could not be initialized properly, check logs for '%s' resultSupplier initialization problems.", this.name));
149         }
150     }
151 
152     protected String constructTargetUrl(SearchQuery query) throws UnsupportedEncodingException {
153         String targetUrl = baseUrl + requestParameters;
154         targetUrl = StringUtils.replace(targetUrl, "${query}", URLEncoder.encode(query.getQuery(), UTF_8.name()));
155         targetUrl = StringUtils.replace(targetUrl, "${editorClause}", constructEditorClause(query));
156         targetUrl = StringUtils.replace(targetUrl, "${dateClause}", constructDateClause(query));
157         return targetUrl;
158     }
159 
160     protected String constructEditorClause(SearchQuery query) {
161         if (CollectionUtils.isNotEmpty(query.getEditors())) {
162             StringJoiner clauses = new StringJoiner(" OR ");
163             query.getEditors().forEach(editor -> clauses.add(definition.getEditorField() + " = '" + editor + "'"));
164             return " AND (" + clauses + ")";
165         }
166         return StringUtils.EMPTY;
167     }
168 
169     protected String constructDateClause(SearchQuery query) {
170         if (query.getStartDate() != null || query.getEndDate() != null) {
171             StringJoiner clauses = new StringJoiner(" AND ");
172             if (query.getStartDate() != null) {
173                 clauses.add(definition.getDateField() + " >= " + query.getStartDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
174             }
175             if (query.getEndDate() != null) {
176                 clauses.add(definition.getDateField() + " <= " + query.getEndDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
177             }
178             return " AND (" + clauses + ")";
179         }
180         return StringUtils.EMPTY;
181     }
182 
183     private Stream<SearchResult> createResults(String resultJson) {
184         ReadContext readContext = JsonPath.parse(resultJson);
185         List<String> titles = readContext.read(definition.getTitleJsonPath());
186         List<String> urls = readContext.read(definition.getNavigationURLJsonPath());
187         List<String> dates = Collections.emptyList();
188         List<String> editors = Collections.emptyList();
189 
190         if (StringUtils.isNotBlank(definition.getDateJsonPath())) {
191             dates = readContext.read(definition.getDateJsonPath());
192         }
193 
194         if (StringUtils.isNotBlank(definition.getEditorJsonPath())) {
195             editors = readContext.read(definition.getEditorJsonPath());
196         }
197 
198         Stream.Builder<SearchResult> results = Stream.builder();
199         // In result JSON titles and urls should be 1-1 and therefore if not we simply do not continue.
200         //
201         // dates and editors, although optional, are also in a 1-1 relationship with titles and urls.
202         // this might not be true for all cases, but so far we assume that a data source either provides
203         // dates and authors for all elements, or for none.
204         final int dataSetLength = titles.size();
205         if (dataSetLength != urls.size() ||
206                 (dataSetLength != dates.size() && dates.size() > 0) ||
207                 (dataSetLength != editors.size() && editors.size() > 0)) {
208             log.debug("There is a mismatch between titles, urls, dates and editors in the data set.");
209             log.debug("Either something is wrong with your JsonPath configuration, or the data is inconsistent.");
210             return results.build();
211         }
212 
213         for (int i = 0; i < titles.size(); i++) {
214             final String url = formatOrAppend(String.valueOf(urls.get(i)));
215             results.add(SearchResult.builder()
216                     .title(String.valueOf(titles.get(i)))
217                     .operationRequest(new ExternalNavigationRequest(url))
218                     .type("external-webpage")
219                     .lastModified(dates.stream().filter(Objects::nonNull).skip(i).findFirst().map(RestSearchResultSupplier::parseToZonedDateTimeOrNull).orElse(null))
220                     .lastModifiedBy(editors.stream().filter(Objects::nonNull).skip(i).findFirst().orElse(""))
221                     .build()
222             );
223         }
224         return results.build();
225     }
226 
227     private static ZonedDateTime parseToZonedDateTimeOrNull(String dateString) {
228         try {
229             return ZonedDateTime.parse(dateString);
230         } catch (DateTimeParseException e) {
231             return null;
232         }
233     }
234 
235     /**
236      * Some configurations can require values to be formatted via replacement tokens.
237      * However, some of them do not need this and then we simply append the given url with the
238      * {@link RestSearchResultSupplierDefinition#navigationBaseURL}.
239      */
240     private String formatOrAppend(String url) {
241         if (definition.getNavigationBaseURL().contains("%")) {
242             return String.format(definition.getNavigationBaseURL(), String.valueOf(url));
243         } else {
244             return definition.getNavigationBaseURL() + url;
245         }
246     }
247 }