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.Optional;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.parameters.ParameterException;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.cocoon.ProcessingException;
030import org.apache.cocoon.environment.ObjectModelHelper;
031import org.apache.cocoon.environment.Request;
032import org.apache.cocoon.environment.Response;
033import org.apache.cocoon.generation.ServiceableGenerator;
034import org.apache.commons.lang3.StringUtils;
035import org.slf4j.Logger;
036import org.xml.sax.SAXException;
037
038import org.ametys.cms.search.solr.SearcherFactory;
039import org.ametys.cms.search.solr.SearcherFactory.Searcher;
040import org.ametys.core.util.AvalonLoggerAdapter;
041import org.ametys.plugins.repository.AmetysObjectResolver;
042import org.ametys.web.WebConstants;
043import org.ametys.web.frontoffice.search.SearchService;
044import org.ametys.web.frontoffice.search.instance.SearchServiceInstance;
045import org.ametys.web.frontoffice.search.instance.SearchServiceInstanceManager;
046import org.ametys.web.frontoffice.search.requesttime.SearchServiceDebugModeHelper.DebugMode;
047import org.ametys.web.frontoffice.search.requesttime.input.InputValidator;
048import org.ametys.web.frontoffice.search.requesttime.input.SearchUserInputs;
049import org.ametys.web.frontoffice.search.requesttime.input.SearchUserInputsRetriever;
050import org.ametys.web.frontoffice.search.requesttime.pagination.Pagination;
051import org.ametys.web.renderingcontext.RenderingContextHandler;
052import org.ametys.web.repository.page.Page;
053import org.ametys.web.repository.page.SitemapElement;
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    @Override
090    public void enableLogging(org.apache.avalon.framework.logger.Logger logger)
091    {
092        super.enableLogging(logger);
093        _logger = new AvalonLoggerAdapter(logger);
094    }
095
096    @Override
097    public void service(ServiceManager smanager) throws ServiceException
098    {
099        super.service(smanager);
100        _searchServiceInstanceManager = (SearchServiceInstanceManager) smanager.lookup(SearchServiceInstanceManager.ROLE);
101        ServiceExtensionPoint serviceEP = (ServiceExtensionPoint) smanager.lookup(ServiceExtensionPoint.ROLE);
102        _searchService = (SearchService) serviceEP.getExtension(SearchService.ROLE);
103        _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE);
104        _searcherFactory = (SearcherFactory) smanager.lookup(SearcherFactory.ROLE);
105        _searchComponentEP = (SearchComponentExtensionPoint) smanager.lookup(SearchComponentExtensionPoint.ROLE);
106        _renderingContextHandler = (RenderingContextHandler) smanager.lookup(RenderingContextHandler.ROLE);
107        _ametysObjectResolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
108        _searchUserInputsRetriever = (SearchUserInputsRetriever) smanager.lookup(SearchUserInputsRetriever.ROLE);
109    }
110    
111    /**
112     * Builds {@link SearchComponentArguments}
113     * @param request The request
114     * @return The built {@link SearchComponentArguments}
115     * @throws ProcessingException If an error occured when processing the request
116     */
117    protected SearchComponentArguments _buildArguments(Request request) throws ProcessingException
118    {
119        Response response = ObjectModelHelper.getResponse(objectModel);
120        String zoneItemId = _retrieveZoneItemId(request);
121        SearchServiceInstance serviceInstance = getInstance(request, zoneItemId);
122        Page currentPage = _retrieveCurrentPage(request, zoneItemId);
123        Searcher searcher = _searcherFactory.create();
124        
125        int resultPageIndex = _retrievePageIndex(request);
126        Pagination pagination = new Pagination(resultPageIndex, serviceInstance.resultsPerPage(), serviceInstance.maxResults());
127        DebugMode debugMode = _debugMode(request);
128        boolean isFormSubmitted = _isFormSubmitted(request);
129        boolean launchSearch = _launchSearch(isFormSubmitted, request, serviceInstance, debugMode);
130        SearchUserInputs userInputs = _searchUserInputsRetriever.getUserInputs(request, isFormSubmitted, serviceInstance);
131        
132        SearchComponentArguments args = new SearchComponentArguments(
133                contentHandler, 
134                parameters,
135                serviceInstance, 
136                _searchService, 
137                userInputs,
138                request, 
139                response, 
140                pagination,
141                _retrieveCurrentSite(request, currentPage), 
142                currentPage, 
143                _retrieveCurrentLang(request, currentPage), 
144                launchSearch, 
145                searcher, 
146                _logger, 
147                debugMode);
148        return args;
149    }
150    
151    @Override
152    public void generate() throws IOException, SAXException, ProcessingException
153    {
154        Request request = ObjectModelHelper.getRequest(objectModel);
155        SearchComponentArguments args = _buildArguments(request);
156        
157        List<IdAwareSearchComponent> components = _searchComponentEP.getExtensionsIds()
158                .stream()
159                .map(id -> IdAwareSearchComponent.of(id, _searchComponentEP))
160                .filter(c -> c._component.supports(args))
161                .sorted(Comparator.comparingLong(c -> c._component.priority()))
162                .collect(Collectors.toList());
163        
164        long allCmpStart = System.currentTimeMillis();
165        String reqIdentifier = _getRequestIdentifier(request);
166        List<SearchComponentError> errors = new ArrayList<>();
167        contentHandler.startDocument();
168        for (IdAwareSearchComponent idAwareComponent : components)
169        {
170            SearchComponent component = idAwareComponent._component;
171            String id = idAwareComponent._id;
172            try
173            {
174                long currCmpStart = System.currentTimeMillis();
175                component.execute(args);
176                String strPriority = _logger.isInfoEnabled() ? new DecimalFormat().format(component.priority()) : null;
177                _logger.info("Execution finished for search component '{}' (priority {}) and request '{}' in {}ms", id, strPriority, reqIdentifier, System.currentTimeMillis() - currCmpStart);
178            }
179            catch (Exception e)
180            {
181                _logger.error("An error occured while executing search component '{}' for request '{}'. Other components will be executed but the result can be inconsistent", id, reqIdentifier, e);
182                errors.add(new SearchComponentError(component, e));
183            }
184        }
185        _logger.info("All search components have been executed for request '{}' in {}ms", reqIdentifier, System.currentTimeMillis() - allCmpStart);
186        _handleErrors(errors);
187        
188        contentHandler.endDocument();
189    }
190    
191    /**
192     * Retrieves the current zone item id
193     * @param request the request
194     * @return the zone item id
195     */
196    protected String _retrieveZoneItemId(Request request)
197    {
198        if (parameters.isParameter(__ZONE_ITEM_GENERATOR_PARAM_NAME))
199        {
200            return parameters.getParameter(__ZONE_ITEM_GENERATOR_PARAM_NAME, "");
201        }
202        
203        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
204        return Optional.ofNullable(zoneItem)
205                    .map(ZoneItem::getId)
206                    .orElse(request.getParameter(__ZONE_ITEM_REQUEST_PARAM_NAME));
207    }
208    
209    /**
210     * Gets the {@link SearchServiceInstance} being processed
211     * @param request the request
212     * @param zoneItemId The zone item id
213     * @return the {@link SearchServiceInstance} being processed
214     */
215    protected SearchServiceInstance getInstance(Request request, String zoneItemId)
216    {
217        if (!_searchServiceInstanceManager.isSearchServiceInstance(zoneItemId))
218        {
219            throw new IllegalArgumentException("No search service instance in zone item '" + zoneItemId + "'");
220        }
221        return _searchServiceInstanceManager.get(zoneItemId);
222    }
223    
224    /**
225     * Retrieves the current page index of the search
226     * @param request the request
227     * @return the current page index of the search
228     * @throws ProcessingException If an error occured when retrieving the page index 
229     */
230    protected int _retrievePageIndex(Request request) throws ProcessingException
231    {
232        if (parameters.isParameter(__PAGINATION_GENERATOR_PARAM_NAME))
233        {
234            try
235            {
236                return parameters.getParameterAsInteger(__PAGINATION_GENERATOR_PARAM_NAME);
237            }
238            catch (ParameterException e)
239            {
240                throw new ProcessingException(e);
241            }
242        }
243        
244        return 1;
245    }
246    
247    /**
248     * Retrieves the current page
249     * @param request the request
250     * @param zoneItemId The zone item id
251     * @return the current page
252     */
253    protected Page _retrieveCurrentPage(Request request, String zoneItemId)
254    {
255        Page page = (Page) request.getAttribute(WebConstants.REQUEST_ATTR_PAGE);
256        if (page == null)
257        {
258            SitemapElement sitemapElement = _ametysObjectResolver.<ZoneItem>resolveById(zoneItemId).getZone().getSitemapElement();
259            if (sitemapElement instanceof Page page2)
260            {
261                return page2;
262            }
263            throw new IllegalStateException("The search service does not supports inheritance from a sitemap root");
264        }
265        return page;
266    }
267    
268    /**
269     * Retrieves the current site
270     * @param request the request
271     * @param page the page
272     * @return the current site
273     */
274    protected Site _retrieveCurrentSite(Request request, SitemapElement page)
275    {
276        String currentSiteName = page != null ? page.getSiteName() : request.getParameter("siteName");
277        return _siteManager.getSite(currentSiteName);
278    }
279    
280    /**
281     * Retrieves the current lang
282     * @param request the request
283     * @param page the page
284     * @return the current lang
285     */
286    protected String _retrieveCurrentLang(Request request, SitemapElement page)
287    {
288        return page != null ? page.getSitemapName() : request.getParameter("lang");
289    }
290    
291    /**
292     * Checks if the form was submitted
293     * @param request The request
294     * @return <code>true</code> if the form was submitted
295     */
296    protected boolean _isFormSubmitted(Request request)
297    {
298        return parameters.getParameterAsBoolean(__SUBMIT_FORM_PARAM_NAME, false) 
299                || request.getParameter(__SUBMIT_FORM_PARAM_NAME) != null;
300    }
301    
302    /**
303     * Tells if the search has to be launched
304     * @param isFormSubmitted <code>true</code> if the form was submitted
305     * @param request The request
306     * @param serviceInstance The service instance being processed
307     * @param debugMode The debug mode
308     * @return <code>true</code> if the search has to be launched
309     */
310    protected boolean _launchSearch(boolean isFormSubmitted, Request request, SearchServiceInstance serviceInstance, DebugMode debugMode)
311    {
312        if (serviceInstance.getResultDisplay()
313                .launchSearchAtStartup()
314                .orElse(false))
315        {
316            // Search has to be launched at startup
317            return true;
318        }
319        
320        return isFormSubmitted
321                && _inputValid(request, serviceInstance, debugMode);
322    }
323    
324    /**
325     * Checks if inputs are valid
326     * @param request The request
327     * @param serviceInstance The service instance being processed
328     * @param debugMode The debug mode
329     * @return <code>true</code> if inputs are valid
330     */
331    protected boolean _inputValid(Request request, SearchServiceInstance serviceInstance, DebugMode debugMode)
332    {
333        ZoneItem zoneItem = (ZoneItem) request.getAttribute(WebConstants.REQUEST_ATTR_ZONEITEM);
334        String submittedFormParamValue = request.getParameter(__SUBMIT_FORM_PARAM_NAME);
335        String fromZoneItemId = request.getParameter(__ZONE_ITEM_REQUEST_PARAM_NAME);
336        
337        InputValidator inputValidator = new InputValidator(serviceInstance, zoneItem, submittedFormParamValue, fromZoneItemId);
338        // if rejected => search has not to be launched
339        // if not rejected => if #validate returns true, it has to be launched. Otherwise the result is unknown, by default we do not launch it
340        return !inputValidator.rejects() && inputValidator.validate();
341    }
342    
343    /**
344     * Gets the debug mode if activated
345     * @param request The request
346     * @return the debug mode if activated
347     */
348    protected DebugMode _debugMode(Request request)
349    {
350        return SearchServiceDebugModeHelper.debugMode(request, _renderingContextHandler);
351    }
352    
353    /**
354     * Gets the identifier of the request for display debug purposes only
355     * @param request the request
356     * @return the identifier of the request for display debug purposes only
357     */
358    protected String _getRequestIdentifier(Request request)
359    {
360        String reqId = request.toString();
361        if (reqId.contains("@"))
362        {
363            reqId = StringUtils.substringAfterLast(reqId, "@");
364        }
365        return reqId;
366    }
367    
368    static class SearchComponentError
369    {
370        SearchComponent _component;
371        Throwable _throwable;
372
373        SearchComponentError(SearchComponent component, Throwable t)
374        {
375            _component = component;
376            _throwable = t;
377        }
378        
379        @Override
380        public String toString()
381        {
382            return "with " + _component;
383        }
384    }
385    
386    /**
387     * Handles errors of {@link SearchComponent}s
388     * @param errors The errors
389     * @throws ProcessingException if the {@link SearchComponentError}s lead to a need to throw an exception
390     */
391    protected void _handleErrors(List<SearchComponentError> errors) throws ProcessingException
392    {
393        if (!errors.isEmpty())
394        {
395            String msg = String.format("Some errors occured during the execution of search components: %s. See the previous error logs to see details.", errors);
396            throw new ProcessingException(msg);
397        }
398    }
399    
400    private static final class IdAwareSearchComponent
401    {
402        final SearchComponent _component;
403        final String _id;
404        
405        private IdAwareSearchComponent(SearchComponent component, String id)
406        {
407            _component = component;
408            _id = id;
409        }
410        
411        static IdAwareSearchComponent of(String id, SearchComponentExtensionPoint searchComponents)
412        {
413            return new IdAwareSearchComponent(searchComponents.getExtension(id), id);
414        }
415        
416        @Override
417        public String toString()
418        {
419            return _id;
420        }
421    }
422}