001/*
002 *  Copyright 2018 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.web.frontoffice.search.requesttime;
017
018import java.io.IOException;
019import java.text.DecimalFormat;
020import java.util.ArrayList;
021import java.util.Comparator;
022import java.util.List;
023import java.util.Map;
024import java.util.Optional;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.parameters.ParameterException;
028import org.apache.avalon.framework.parameters.Parameters;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.cocoon.ProcessingException;
032import org.apache.cocoon.environment.ObjectModelHelper;
033import org.apache.cocoon.environment.Request;
034import org.apache.cocoon.environment.Response;
035import org.apache.cocoon.environment.SourceResolver;
036import org.apache.cocoon.generation.ServiceableGenerator;
037import org.apache.commons.lang3.StringUtils;
038import org.slf4j.Logger;
039import org.xml.sax.SAXException;
040
041import org.ametys.cms.search.solr.SearcherFactory;
042import org.ametys.cms.search.solr.SearcherFactory.Searcher;
043import org.ametys.core.util.AvalonLoggerAdapter;
044import org.ametys.plugins.repository.AmetysObjectResolver;
045import org.ametys.web.WebConstants;
046import org.ametys.web.frontoffice.search.SearchService;
047import org.ametys.web.frontoffice.search.instance.SearchServiceInstance;
048import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager;
049import org.ametys.web.frontoffice.search.requesttime.SearchServiceDebugModeHelper.DebugMode;
050import org.ametys.web.frontoffice.search.requesttime.input.SearchUserInputs;
051import org.ametys.web.frontoffice.search.requesttime.input.SearchUserInputsRetriever;
052import org.ametys.web.renderingcontext.RenderingContextHandler;
053import org.ametys.web.repository.page.Page;
054import org.ametys.web.repository.page.ZoneItem;
055import org.ametys.web.repository.site.Site;
056import org.ametys.web.repository.site.SiteManager;
057import org.ametys.web.service.Service;
058import org.ametys.web.service.ServiceExtensionPoint;
059
060/**
061 * Generator for the search service.
062 */
063public class SearchServiceGenerator extends ServiceableGenerator
064{
065    static final String __ZONE_ITEM_REQUEST_PARAM_NAME = "zone-item-id";
066    static final String __ZONE_ITEM_GENERATOR_PARAM_NAME = "zone-item-id";
067    static final String __PAGINATION_GENERATOR_PARAM_NAME = "pagination-index";
068    static final String __SUBMIT_FORM_PARAM_NAME = "submit-form";
069    
070    /** The SL4j logger */
071    protected Logger _logger;
072    /** The manager for {@link SearchServiceInstance}s */
073    protected SearchServiceInstanceManager _searchServiceInstanceManager;
074    /** The search {@link Service} */
075    protected SearchService _searchService;
076    /** The site manager */
077    protected SiteManager _siteManager;
078    /** The searcher factory */
079    protected SearcherFactory _searcherFactory;
080    /** The extension point for {@link SearchComponent}s */
081    protected SearchComponentExtensionPoint _searchComponentEP;
082    /** The handler of rendering context */
083    protected RenderingContextHandler _renderingContextHandler;
084    /** The resolver for Ametys objects */
085    protected AmetysObjectResolver _ametysObjectResolver;
086    /** The search user inputs retriever */
087    protected SearchUserInputsRetriever _searchUserInputsRetriever;
088    
089    /** The setter of request attributes */
090    protected SearchServiceRequestAttributeSetter _requestAttributeSetter = new SearchServiceRequestAttributeSetter(this);
091    
092    @Override
093    public void enableLogging(org.apache.avalon.framework.logger.Logger logger)
094    {
095        super.enableLogging(logger);
096        _logger = new AvalonLoggerAdapter(logger);
097    }
098
099    @Override
100    public void service(ServiceManager smanager) throws ServiceException
101    {
102        super.service(smanager);
103        _searchServiceInstanceManager = (SearchServiceInstanceManager) smanager.lookup(SearchServiceInstanceManager.ROLE);
104        ServiceExtensionPoint serviceEP = (ServiceExtensionPoint) smanager.lookup(ServiceExtensionPoint.ROLE);
105        _searchService = (SearchService) serviceEP.getExtension(SearchService.ROLE);
106        _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE);
107        _searcherFactory = (SearcherFactory) smanager.lookup(SearcherFactory.ROLE);
108        _searchComponentEP = (SearchComponentExtensionPoint) smanager.lookup(SearchComponentExtensionPoint.ROLE);
109        _renderingContextHandler = (RenderingContextHandler) smanager.lookup(RenderingContextHandler.ROLE);
110        _ametysObjectResolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
111        _searchUserInputsRetriever = (SearchUserInputsRetriever) smanager.lookup(SearchUserInputsRetriever.ROLE);
112    }
113    
114    @Override
115    public void setup(SourceResolver sourceResolver, Map objModel, String src, Parameters par) throws ProcessingException, SAXException, IOException
116    {
117        super.setup(sourceResolver, objModel, src, par);
118        Request request = ObjectModelHelper.getRequest(objModel);
119        _requestAttributeSetter.setAttributes(request, par);
120    }
121    
122    /**
123     * Builds {@link SearchComponentArguments}
124     * @param request The request
125     * @return The built {@link SearchComponentArguments}
126     * @throws ProcessingException If an error occured when processing the request
127     */
128    protected SearchComponentArguments _buildArguments(Request request) throws ProcessingException
129    {
130        Response response = ObjectModelHelper.getResponse(objectModel);
131        String zoneItemId = _retrieveZoneItemId(request);
132        SearchServiceInstance serviceInstance = getInstance(request, zoneItemId);
133        Page currentPage = _retrieveCurrentPage(request, zoneItemId);
134        Searcher searcher = _searcherFactory.create();
135        
136        int resultPageIndex = _retrievePageIndex(request);
137        DebugMode debugMode = _debugMode(request);
138        boolean isFormSubmitted = _isFormSubmitted(request);
139        boolean launchSearch = (isFormSubmitted || serviceInstance.getResultDisplay().launchSearchAtStartup().orElse(false)) && _inputValid(request, serviceInstance, debugMode);
140        SearchUserInputs userInputs = _searchUserInputsRetriever.getUserInputs(request, isFormSubmitted, serviceInstance);
141        
142        SearchComponentArguments args = new SearchComponentArguments(
143                contentHandler, 
144                parameters,
145                serviceInstance, 
146                _searchService, 
147                userInputs,
148                request, 
149                response, 
150                resultPageIndex, 
151                _computeStartIndex(resultPageIndex, serviceInstance), 
152                _computeResultsPerPage(serviceInstance), 
153                _retrieveCurrentSite(request, currentPage), 
154                currentPage, 
155                _retrieveCurrentLang(request, currentPage), 
156                launchSearch, 
157                searcher, 
158                _logger, 
159                debugMode);
160        return args;
161    }
162    
163    @Override
164    public void generate() throws IOException, SAXException, ProcessingException
165    {
166        Request request = ObjectModelHelper.getRequest(objectModel);
167        SearchComponentArguments args = _buildArguments(request);
168        
169        List<SearchComponent> components = _searchComponentEP.getExtensionsIds()
170                .stream()
171                .map(_searchComponentEP::getExtension)
172                .filter(c -> c.supports(args))
173                .sorted(Comparator.comparingLong(SearchComponent::priority))
174                .collect(Collectors.toList());
175        
176        long allCmpStart = System.currentTimeMillis();
177        String reqIdentifier = _getRequestIdentifier(request);
178        List<SearchComponentError> errors = new ArrayList<>();
179        contentHandler.startDocument();
180        for (SearchComponent component : components)
181        {
182            try
183            {
184                long currCmpStart = System.currentTimeMillis();
185                component.execute(args);
186                String strPriority = _logger.isInfoEnabled() ? new DecimalFormat().format(component.priority()) : null;
187                _logger.info("Execution finished for search component '{}' (priority {}) and request '{}' in {}ms", component, strPriority, reqIdentifier, System.currentTimeMillis() - currCmpStart);
188            }
189            catch (Exception e)
190            {
191                _logger.error("An error occured while executing search component '{}' for request '{}'. Other components will be executed but the result can be inconsistent", component, reqIdentifier, e);
192                errors.add(new SearchComponentError(component, e));
193            }
194        }
195        _handleErrors(errors);
196        
197        contentHandler.endDocument();
198        _logger.info("All search components have been executed for request '{}' in {}ms", reqIdentifier, System.currentTimeMillis() - allCmpStart);
199    }
200    
201    /**
202     * Retrieves the current zone item id
203     * @param request the request
204     * @return the zone item id
205     */
206    protected String _retrieveZoneItemId(Request request)
207    {
208        if (parameters.isParameter(__ZONE_ITEM_GENERATOR_PARAM_NAME))
209        {
210            return parameters.getParameter(__ZONE_ITEM_GENERATOR_PARAM_NAME, "");
211        }
212        
213        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
214        return Optional.ofNullable(zoneItem)
215                    .map(ZoneItem::getId)
216                    .orElse(request.getParameter(__ZONE_ITEM_REQUEST_PARAM_NAME));
217    }
218    
219    /**
220     * Gets the {@link SearchServiceInstance} being processed
221     * @param request the request
222     * @param zoneItemId The zone item id
223     * @return the {@link SearchServiceInstance} being processed
224     */
225    protected SearchServiceInstance getInstance(Request request, String zoneItemId)
226    {
227        if (!_searchServiceInstanceManager.isSearchServiceInstance(zoneItemId))
228        {
229            throw new IllegalArgumentException("No search service instance in zone item '" + zoneItemId + "'");
230        }
231        return _searchServiceInstanceManager.get(zoneItemId);
232    }
233    
234    /**
235     * Retrieves the current page index of the search
236     * @param request the request
237     * @return the current page index of the search
238     * @throws ProcessingException If an error occured when retrieving the page index 
239     */
240    protected int _retrievePageIndex(Request request) throws ProcessingException
241    {
242        if (parameters.isParameter(__PAGINATION_GENERATOR_PARAM_NAME))
243        {
244            try
245            {
246                return parameters.getParameterAsInteger(__PAGINATION_GENERATOR_PARAM_NAME);
247            }
248            catch (ParameterException e)
249            {
250                throw new ProcessingException(e);
251            }
252        }
253        
254        return 1;
255    }
256    
257    /**
258     * Gets the number of results per page
259     * @param serviceInstance the search service instance
260     * @return the real number of results per page
261     */
262    protected Optional<Integer> _resultsPerPage(SearchServiceInstance serviceInstance)
263    {
264        Optional<Integer> paramResultsPerPage = serviceInstance.resultsPerPage();
265        return paramResultsPerPage.isPresent() ? paramResultsPerPage : serviceInstance.maxResults(); // Can still be empty if resultsPerPage AND maxResults are empty
266    }
267    
268    /**
269     * Computes the start index (i.e. offset) of the search
270     * @param pageIndex the page index
271     * @param serviceInstance the search service instance
272     * @return the start index
273     */
274    protected int _computeStartIndex(int pageIndex, SearchServiceInstance serviceInstance)
275    {
276        Optional<Integer> resultsPerPage = _resultsPerPage(serviceInstance);
277        int start = resultsPerPage.isPresent() ? (pageIndex - 1) * resultsPerPage.get() : 0;
278        return start;
279    }
280    
281    /**
282     * Computes the number of results per page
283     * @param serviceInstance the search service instance
284     * @return the number of results per page
285     */
286    protected int _computeResultsPerPage(SearchServiceInstance serviceInstance)
287    {
288        Optional<Integer> resultsPerPage = _resultsPerPage(serviceInstance);
289        return resultsPerPage.orElse(Integer.MAX_VALUE);
290    }
291    
292    /**
293     * Retrieves the current page
294     * @param request the request
295     * @param zoneItemId The zone item id
296     * @return the current page
297     */
298    protected Page _retrieveCurrentPage(Request request, String zoneItemId)
299    {
300        Page page = (Page) request.getAttribute(WebConstants.REQUEST_ATTR_PAGE);
301        if (page == null)
302        {
303            return _ametysObjectResolver.<ZoneItem>resolveById(zoneItemId).getZone().getPage();
304        }
305        return page;
306    }
307    
308    /**
309     * Retrieves the current site
310     * @param request the request
311     * @param page the page
312     * @return the current site
313     */
314    protected Site _retrieveCurrentSite(Request request, Page page)
315    {
316        String currentSiteName = page != null ? page.getSiteName() : request.getParameter("siteName");
317        return _siteManager.getSite(currentSiteName);
318    }
319    
320    /**
321     * Retrieves the current lang
322     * @param request the request
323     * @param page the page
324     * @return the current lang
325     */
326    protected String _retrieveCurrentLang(Request request, Page page)
327    {
328        return page != null ? page.getSitemapName() : request.getParameter("lang");
329    }
330    
331    /**
332     * Checks if the form was submitted
333     * @param request The request
334     * @return <code>true</code> if the form was submitted
335     */
336    protected boolean _isFormSubmitted(Request request)
337    {
338        return parameters.getParameterAsBoolean(__SUBMIT_FORM_PARAM_NAME, false) || request.getParameter(__SUBMIT_FORM_PARAM_NAME) != null;
339    }
340    
341    /**
342     * Checks if inputs are valid
343     * @param request The request
344     * @param serviceInstance The service instance being processed
345     * @param debugMode The debug mode
346     * @return <code>true</code> if inputs are valid
347     */
348    protected boolean _inputValid(Request request, SearchServiceInstance serviceInstance, DebugMode debugMode)
349    {
350        if (debugMode != null)
351        {
352            // Facilitate debugging, 'submit-form' parameter should be sufficient to debug
353            return true;
354        }
355        
356        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
357        String submittedForm = request.getParameter(__SUBMIT_FORM_PARAM_NAME);
358        String serviceGroupId = serviceInstance.getResultDisplay().serviceGroupId();
359        String fromZoneItemId = request.getParameter(__ZONE_ITEM_REQUEST_PARAM_NAME);
360        
361        return zoneItem == null // Be sure to return true if request is not part of a service
362                || serviceInstance.getResultDisplay().launchSearchAtStartup().orElse(false) // Search has to be launched at startup
363                || StringUtils.isEmpty(submittedForm) && fromZoneItemId == null // Request comes from another page with a search service configured with ResultDisplayType.ON_PAGE and no service group id
364                || StringUtils.isNotEmpty(serviceGroupId) && serviceGroupId.equals(submittedForm) // Same service group ids
365                || zoneItem.getId().equals(fromZoneItemId); // Same zone item id
366    }
367    
368    /**
369     * Gets the debug mode if activated
370     * @param request The request
371     * @return the debug mode if activated
372     */
373    protected DebugMode _debugMode(Request request)
374    {
375        return SearchServiceDebugModeHelper.debugMode(request, _renderingContextHandler);
376    }
377    
378    /**
379     * Gets the identifier of the request for display debug purposes only
380     * @param request the request
381     * @return the identifier of the request for display debug purposes only
382     */
383    protected String _getRequestIdentifier(Request request)
384    {
385        String reqId = request.toString();
386        if (reqId.contains("@"))
387        {
388            reqId = StringUtils.substringAfterLast(reqId, "@");
389        }
390        return reqId;
391    }
392    
393    static class SearchComponentError
394    {
395        SearchComponent _component;
396        Throwable _throwable;
397
398        SearchComponentError(SearchComponent component, Throwable t)
399        {
400            _component = component;
401            _throwable = t;
402        }
403        
404        @Override
405        public String toString()
406        {
407            return "with " + _component;
408        }
409    }
410    
411    /**
412     * Handles errors of {@link SearchComponent}s
413     * @param errors The errors
414     * @throws ProcessingException if the {@link SearchComponentError}s lead to a need to throw an exception
415     */
416    protected void _handleErrors(List<SearchComponentError> errors) throws ProcessingException
417    {
418        if (!errors.isEmpty())
419        {
420            String msg = String.format("Some errors occured during the execution of search components: %s. See the previous error logs to see details.", errors);
421            throw new ProcessingException(msg);
422        }
423    }
424}