View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.oaipmh.server;
22  
23  import static java.lang.String.format;
24  import static org.opencastproject.oaipmh.OaiPmhUtil.toOaiRepresentation;
25  import static org.opencastproject.oaipmh.OaiPmhUtil.toUtc;
26  import static org.opencastproject.oaipmh.persistence.QueryBuilder.queryRepo;
27  import static org.opencastproject.oaipmh.server.Functions.addDay;
28  import static org.opencastproject.util.data.Prelude.unexhaustiveMatch;
29  import static org.opencastproject.util.data.functions.Misc.chuck;
30  
31  import org.opencastproject.mediapackage.MediaPackage;
32  import org.opencastproject.oaipmh.Granularity;
33  import org.opencastproject.oaipmh.OaiPmhConstants;
34  import org.opencastproject.oaipmh.OaiPmhUtil;
35  import org.opencastproject.oaipmh.persistence.OaiPmhDatabase;
36  import org.opencastproject.oaipmh.persistence.OaiPmhDatabaseException;
37  import org.opencastproject.oaipmh.persistence.OaiPmhSetDefinition;
38  import org.opencastproject.oaipmh.persistence.OaiPmhSetDefinitionFilter;
39  import org.opencastproject.oaipmh.persistence.OaiPmhSetDefinitionImpl;
40  import org.opencastproject.oaipmh.persistence.SearchResult;
41  import org.opencastproject.oaipmh.persistence.SearchResultItem;
42  import org.opencastproject.oaipmh.util.XmlGen;
43  
44  import org.apache.commons.collections4.EnumerationUtils;
45  import org.apache.commons.lang3.StringUtils;
46  import org.osgi.service.cm.ConfigurationException;
47  import org.osgi.service.cm.ManagedService;
48  import org.slf4j.Logger;
49  import org.slf4j.LoggerFactory;
50  import org.w3c.dom.Element;
51  import org.w3c.dom.Node;
52  
53  import java.util.ArrayList;
54  import java.util.Calendar;
55  import java.util.Collections;
56  import java.util.Date;
57  import java.util.Dictionary;
58  import java.util.LinkedList;
59  import java.util.List;
60  import java.util.Optional;
61  import java.util.function.Function;
62  import java.util.function.Supplier;
63  import java.util.stream.Collectors;
64  import java.util.stream.Stream;
65  
66  /**
67   * An OAI-PMH protocol compliant repository.
68   * <p>
69   * Currently supported:
70   * <ul>
71   * <li></li>
72   * </ul>
73   * <p>
74   * Currently <em>not</em> supported:
75   * <ul>
76   * <li><a href="http://www.openarchives.org/OAI/openarchivesprotocol.html#deletion">deletions</a></li>
77   * <li><a href="http://www.openarchives.org/OAI/openarchivesprotocol.html#Set">sets</a></li>
78   * <li>&lt;about&gt; containers in records, see section <a
79   * href="http://www.openarchives.org/OAI/openarchivesprotocol.html#Record">2.5. Record</a></li>
80   * <li>
81   * resumption tokens do not report about their expiration date; see section <a
82   * href="http://www.openarchives.org/OAI/openarchivesprotocol.html#FlowControl">3.5. Flow Control</a></li>
83   * </ul>
84   */
85  // todo - malformed date parameter must produce a BadArgument error - if a date parameter has a finer granularity than
86  //        supported by the repository this must produce a BadArgument error
87  public abstract class OaiPmhRepository implements ManagedService {
88    private static final Logger logger = LoggerFactory.getLogger(OaiPmhRepository.class);
89    private static final OaiDcMetadataProvider OAI_DC_METADATA_PROVIDER = new OaiDcMetadataProvider();
90    private static final String OAI_NS = OaiPmhConstants.OAI_2_0_XML_NS;
91  
92    private static final String CONF_KEY_SET_PREFIX = "set.";
93    private static final String CONF_KEY_SET_SETSPEC_SUFFIX = ".setSpec";
94    private static final String CONF_KEY_SET_NAME_SUFFIX = ".name";
95    private static final String CONF_KEY_SET_DESCRIPTION_SUFFIX = ".description";
96    private static final String CONF_KEY_SET_FILTER_INFIX = ".filter.";
97    private static final String CONF_KEY_SET_FILTER_FLAVOR_SUFFIX = ".flavor";
98    private static final String CONF_KEY_SET_FILTER_CONTAINS_SUFFIX = ".contains";
99    private static final String CONF_KEY_SET_FILTER_CONTAINSNOT_SUFFIX = ".containsnot";
100   private static final String CONF_KEY_SET_FILTER_MATCH_SUFFIX = ".match";
101 
102 
103   public abstract Granularity getRepositoryTimeGranularity();
104 
105   /** Display name of the OAI-PMH repository. */
106   public abstract String getRepositoryName();
107 
108   /** Repository ID. */
109   public abstract String getRepositoryId();
110 
111   public abstract OaiPmhDatabase getPersistence();
112 
113   public abstract String getAdminEmail();
114 
115   private List<OaiPmhSetDefinition> sets = new ArrayList<>();
116 
117   /**
118    * Parse service configuration file.
119    *
120    * @param properties
121    *        Service configuration as dictionary
122    * @throws ConfigurationException
123    *        If there is a problem within get configuration
124    */
125   @Override
126   public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
127     if (properties == null) {
128       return;
129     }
130 
131     // Wipe set configuration in case some got removed
132     sets = new ArrayList<>();
133     List<String> confKeys = EnumerationUtils.toList(properties.keys());
134     for (String confKey : confKeys) {
135       if (confKey.startsWith(CONF_KEY_SET_PREFIX) && confKey.endsWith(CONF_KEY_SET_SETSPEC_SUFFIX)) {
136         String confKeyPrefix = confKey.replace(CONF_KEY_SET_SETSPEC_SUFFIX, "");
137         String setSpec = (String) properties.get(confKey);
138         String setSpecName = (String) properties.get(confKeyPrefix + CONF_KEY_SET_NAME_SUFFIX);
139         String setDescription = null;
140         if (confKey.contains(confKeyPrefix + CONF_KEY_SET_DESCRIPTION_SUFFIX)) {
141           setDescription = (String) properties.get(confKeyPrefix + CONF_KEY_SET_DESCRIPTION_SUFFIX);
142         }
143         try {
144           OaiPmhSetDefinitionImpl setDefinition = OaiPmhSetDefinitionImpl.build(setSpec, setSpecName, setDescription);
145           List<String> confKeyFilterNames = confKeys.stream()
146               .filter(key -> key.startsWith(confKeyPrefix + CONF_KEY_SET_FILTER_INFIX)
147                   && key.endsWith(CONF_KEY_SET_FILTER_FLAVOR_SUFFIX))
148               .map(key -> key.replace(confKeyPrefix + CONF_KEY_SET_FILTER_INFIX, "")
149                   .replace(CONF_KEY_SET_FILTER_FLAVOR_SUFFIX, ""))
150               .distinct().collect(Collectors.toList());
151           for (String filterName : confKeyFilterNames) {
152             String setSpecFilterFlavor = (String) properties
153                 .get(confKeyPrefix + CONF_KEY_SET_FILTER_INFIX + filterName + CONF_KEY_SET_FILTER_FLAVOR_SUFFIX);
154             List<String> confKeyCriteria = confKeys.stream()
155                 .filter(key -> key.startsWith(confKeyPrefix + CONF_KEY_SET_FILTER_INFIX + filterName)
156                     && (key.endsWith(CONF_KEY_SET_FILTER_CONTAINS_SUFFIX)
157                     || key.endsWith(CONF_KEY_SET_FILTER_CONTAINSNOT_SUFFIX)
158                     || key.endsWith(CONF_KEY_SET_FILTER_MATCH_SUFFIX)))
159                 .distinct().collect(Collectors.toList());
160             for (String confKeyCriterion : confKeyCriteria) {
161               String criterion = null;
162               if (confKeyCriterion.endsWith(CONF_KEY_SET_FILTER_CONTAINS_SUFFIX)) {
163                 criterion = OaiPmhSetDefinitionFilter.CRITERION_CONTAINS;
164               } else if (confKeyCriterion.endsWith(CONF_KEY_SET_FILTER_CONTAINSNOT_SUFFIX)) {
165                 criterion = OaiPmhSetDefinitionFilter.CRITERION_CONTAINSNOT;
166               } else if (confKeyCriterion.endsWith(CONF_KEY_SET_FILTER_MATCH_SUFFIX)) {
167                 criterion = OaiPmhSetDefinitionFilter.CRITERION_MATCH;
168               } else {
169                 logger.warn("Configuration key {} not valid.", confKeyCriterion);
170                 continue;
171               }
172               setDefinition.addFilter(filterName, setSpecFilterFlavor, criterion,
173                   (String) properties.get(confKeyCriterion));
174             }
175           }
176           if (setDefinition.getFilters().isEmpty()) {
177             logger.warn("No filter criteria defined for OAI-PMH set definition {}.", setDefinition.getSetSpec());
178           } else {
179             sets.add(setDefinition);
180             logger.debug("OAI-PMH set difinition {} initialized.", setDefinition.getSetSpec());
181           }
182         } catch (IllegalArgumentException e) {
183           logger.warn("Unable to parse OAI-PMH set definition for setSpec {}.", setSpec, e);
184         }
185       }
186     }
187   }
188 
189   /**
190    * Save a query.
191    *
192    * @return a resumption token
193    */
194   public abstract String saveQuery(ResumableQuery query);
195 
196   /** Get a saved query. */
197   public abstract Optional<ResumableQuery> getSavedQuery(String resumptionToken);
198 
199   /** Maximum number of items returned by the list queries ListIdentifiers, ListRecords and ListSets. */
200   public abstract int getResultLimit();
201 
202   /**
203    * Return a list of available metadata providers. Please do not expose the default provider for the
204    * mandatory oai_dc format since this is automatically added when calling {@link #getMetadataProviders()}.
205    *
206    * @see #getMetadataProviders()
207    */
208   public abstract List<MetadataProvider> getRepositoryMetadataProviders();
209 
210   /** Return the current date. Used in implementation instead of new Date(); to facilitate unit testing. */
211   public Date currentDate() {
212     return new Date();
213   }
214 
215   /** Return a list of all available metadata providers. The <code>oai_dc</code> format is always included. */
216   public final List<MetadataProvider> getMetadataProviders() {
217     return Stream.concat(
218         Stream.of(OAI_DC_METADATA_PROVIDER),
219         getRepositoryMetadataProviders().stream()
220     ).toList();
221   }
222 
223   /** Add an item to the repository. */
224   public void addItem(MediaPackage mp) {
225     getPersistence().search(queryRepo(getRepositoryId()).build());
226     try {
227       getPersistence().store(mp, getRepositoryId());
228     } catch (OaiPmhDatabaseException e) {
229       chuck(e);
230     }
231   }
232 
233   /** Create an OAI-PMH response based on the given request params. */
234   public XmlGen selectVerb(Params p) {
235     if (p.isVerbListIdentifiers()) {
236       return handleListIdentifiers(p);
237     } else if (p.isVerbListRecords()) {
238       return handleListRecords(p);
239     } else if (p.isVerbGetRecord()) {
240       return handleGetRecord(p);
241     } else if (p.isVerbIdentify()) {
242       return handleIdentify(p);
243     } else if (p.isVerbListMetadataFormats()) {
244       return handleListMetadataFormats(p);
245     } else if (p.isVerbListSets()) {
246       return handleListSets(p);
247     } else {
248       return createErrorResponse(
249               "badVerb", Optional.<String>empty(), p.getRepositoryUrl(), "Illegal OAI verb or verb is missing.");
250     }
251   }
252 
253   /** Return the metadata provider for a given metadata prefix. */
254   public Optional<MetadataProvider> getMetadataProvider(final String metadataPrefix) {
255     return getMetadataProviders().stream()
256         .filter(metadataProvider -> metadataProvider.getMetadataFormat().getPrefix().equals(metadataPrefix))
257         .findFirst();
258   }
259 
260   /** Create the "GetRecord" response. */
261   private XmlGen handleGetRecord(final Params p) {
262     if (p.getIdentifier().isEmpty() || p.getMetadataPrefix().isEmpty()) {
263       return createBadArgumentResponse(p);
264     } else {
265       var metadataProviders = p.getMetadataPrefix().flatMap(mp -> getMetadataProvider(mp)).stream().toList();
266       for (final MetadataProvider metadataProvider : metadataProviders) {
267         if (p.getSet().isPresent() && !sets.stream().anyMatch(
268             setDef -> StringUtils.equals(setDef.getSetSpec(), p.getSet().get()))) {
269           // If there is no set specification, immediately return a no result response
270           return createNoRecordsMatchResponse(p);
271         }
272         final SearchResult res = getPersistence()
273                 .search(queryRepo(getRepositoryId()).mediaPackageId(p.getIdentifier())
274                                                     .setDefinitions(sets)
275                                                     .setSpec(p.getSet().orElse(null)).build());
276         final List<SearchResultItem> items = res.getItems();
277         switch (items.size()) {
278           case 0:
279             return createIdDoesNotExistResponse(p);
280           case 1:
281             final SearchResultItem item = items.get(0);
282             return new OaiVerbXmlGen(OaiPmhRepository.this, p) {
283               @Override
284               public Element create() {
285                 // create the metadata for this item
286                 Element metadata = metadataProvider.createMetadata(OaiPmhRepository.this, item, p.getSet());
287                 return oai(request($a("identifier", p.getIdentifier().get()), metadataPrefixAttr(p)),
288                            verb(record(item, metadata)));
289               }
290             };
291           default:
292             throw new RuntimeException("ERROR: Search index contains more than one item with id "
293                                                + p.getIdentifier());
294         }
295       }
296       // no metadata provider found
297       return createCannotDisseminateFormatResponse(p);
298     }
299   }
300 
301   /** Handle the "Identify" request. */
302   /*<Identify >
303     <repositoryName>Test OAI Repository</repositoryName>
304     <baseURL>http://localhost/oaipmh</baseURL>
305     <protocolVersion>2.0</protocolVersion>
306     <adminEmail>admin@localhost.org</adminEmail>
307     <earliestDatestamp>2010-01-01</earliestDatestamp>
308     <deletedRecord>transient</deletedRecord>
309     <granularity>YYYY-MM-DD</granularity>
310   </Identify>*/
311   private XmlGen handleIdentify(final Params p) {
312     return new OaiVerbXmlGen(this, p) {
313       @Override
314       public Element create() {
315         return oai(
316                 request(),
317                 verb($eTxt("repositoryName", OAI_NS, getRepositoryName()),
318                      $eTxt("baseURL", OAI_NS, p.getRepositoryUrl()),
319                      $eTxt("protocolVersion", OAI_NS, "2.0"),
320                      $eTxt("adminEmail", OAI_NS, getAdminEmail()),
321                      $eTxt("earliestDatestamp", OAI_NS, "2010-01-01"),
322                      $eTxt("deletedRecord", OAI_NS, "transient"),
323                      $eTxt("granularity", OAI_NS, toOaiRepresentation(getRepositoryTimeGranularity()))));
324       }
325     };
326   }
327 
328   private XmlGen handleListMetadataFormats(final Params p) {
329     if (p.getIdentifier().isPresent()) {
330       final SearchResult res = getPersistence().search(queryRepo(
331           getRepositoryId()).mediaPackageId(p.getIdentifier().get()).build());
332       if (res.getItems().size() != 1) {
333         return createIdDoesNotExistResponse(p);
334       }
335     }
336     return new OaiVerbXmlGen(this, p) {
337       @Override
338       public Element create() {
339         final List<Node> metadataFormats = getMetadataProviders().stream()
340             .map(metadataProvider -> (Node) metadataFormat(metadataProvider.getMetadataFormat()))
341             .toList();
342         return oai(request($aSome("identifier", p.getIdentifier())), verb(metadataFormats));
343       }
344     };
345   }
346 
347   private XmlGen handleListRecords(final Params p) {
348     final ListItemsEnv env = new ListItemsEnv() {
349       @Override
350       protected ListXmlGen respond(ListGenParams listParams) {
351         return new ListXmlGen(listParams) {
352           @Override
353           protected List<Node> createContent(final Optional<String> set) {
354             return params.getResult().getItems().stream()
355                 .map(new Function<SearchResultItem, Node>() {
356                   @Override
357                   public Node apply(SearchResultItem item) {
358                     logger.debug("Requested set: {}", set);
359                     final Element metadata = params.getMetadataProvider()
360                         .createMetadata(OaiPmhRepository.this, item, set);
361                     return record(item, metadata);
362                   }
363                 })
364                 .toList();
365           }
366         };
367       }
368     };
369     return env.apply(p);
370   }
371 
372   private XmlGen handleListIdentifiers(final Params p) {
373     final ListItemsEnv env = new ListItemsEnv() {
374       @Override
375       protected ListXmlGen respond(ListGenParams listParams) {
376         // create XML response
377         return new ListXmlGen(listParams) {
378           protected List<Node> createContent(Optional<String> set) {
379             return params.getResult().getItems().stream()
380                 .map(item -> (Node) header(item))
381                 .toList();
382           }
383         };
384       }
385     };
386     return env.apply(p);
387   }
388 
389   /** Create the "ListSets" response. Since the list is short the complete list is returned at once. */
390   private XmlGen handleListSets(final Params p) {
391     return new OaiVerbXmlGen(this, p) {
392       @Override
393       public Element create() {
394         if (sets.isEmpty()) {
395           return createNoSetHierarchyResponse(p).create();
396         }
397         List<Node> setNodes = new LinkedList<>();
398         sets.forEach(set -> {
399           String setSpec = set.getSetSpec();
400           String name = set.getName();
401           String description = set.getDescription();
402           if (StringUtils.isNotBlank(description)) {
403             setNodes.add($e("set", $eTxt("setSpec", setSpec), $eTxt("setName", name),
404                 $e("setDescription", dc($eTxt("dc:description", description)))));
405           } else {
406             setNodes.add($e("set", $eTxt("setSpec", setSpec), $eTxt("setName", name)));
407           }
408         });
409         return oai(request(), verb(setNodes));
410       }
411     };
412   }
413 
414   // --
415 
416   private XmlGen createCannotDisseminateFormatResponse(Params p) {
417     return createErrorResponse(
418             OaiPmhConstants.ERROR_CANNOT_DISSEMINATE_FORMAT, p.getVerb(), p.getRepositoryUrl(),
419             "The metadata format identified by the value given for the metadataPrefix argument is not supported by the "
420                 + "item or by the repository.");
421   }
422 
423   private XmlGen createIdDoesNotExistResponse(Params p) {
424     return createErrorResponse(
425             OaiPmhConstants.ERROR_ID_DOES_NOT_EXIST, p.getVerb(), p.getRepositoryUrl(),
426             format("The requested id %s does not exist in the repository.",
427                    p.getIdentifier().orElse("?")));
428   }
429 
430   private XmlGen createBadArgumentResponse(Params p) {
431     return createErrorResponse(OaiPmhConstants.ERROR_BAD_ARGUMENT, p.getVerb(), p.getRepositoryUrl(),
432                                "The request includes illegal arguments or is missing required arguments.");
433   }
434 
435   private XmlGen createBadResumptionTokenResponse(Params p) {
436     return createErrorResponse(
437             OaiPmhConstants.ERROR_BAD_RESUMPTION_TOKEN, p.getVerb(), p.getRepositoryUrl(),
438             "The value of the resumptionToken argument is either invalid or expired.");
439   }
440 
441   private XmlGen createNoRecordsMatchResponse(Params p) {
442     return createErrorResponse(
443             OaiPmhConstants.ERROR_NO_RECORDS_MATCH, p.getVerb(), p.getRepositoryUrl(),
444             "The combination of the values of the from, until, and set arguments results in an empty list.");
445   }
446 
447   private XmlGen createNoSetHierarchyResponse(Params p) {
448     return createErrorResponse(
449             OaiPmhConstants.ERROR_NO_SET_HIERARCHY, p.getVerb(), p.getRepositoryUrl(),
450             "This repository does not support sets");
451   }
452 
453   private XmlGen createErrorResponse(
454           final String code, final Optional<String> verb, final String repositoryUrl, final String msg) {
455     return new OaiXmlGen(this) {
456       @Override
457       public Element create() {
458         return oai($e("request",
459                       OaiPmhConstants.OAI_2_0_XML_NS,
460                       $aSome("verb", verb),
461                       $txt(repositoryUrl)),
462                    $e("error", OaiPmhConstants.OAI_2_0_XML_NS, $a("code", code), $cdata(msg)));
463       }
464     };
465   }
466 
467   // --
468 
469   /**
470    * Convert a date to the repository supported time granularity.
471    *
472    * @return the converted date or null if d is null
473    */
474   String toSupportedGranularity(Date d) {
475     return toUtc(d, getRepositoryTimeGranularity());
476   }
477 
478   private Date granulate(Date date) {
479     return granulate(getRepositoryTimeGranularity(), date);
480   }
481 
482   /**
483    * "Cut" a date to the repositories supported granularity. Cutting behaves similar to the mathematical floor function.
484    */
485   public static Date granulate(Granularity g, Date d) {
486     switch (g) {
487       case SECOND: {
488         Calendar c = Calendar.getInstance();
489         c.setTimeZone(OaiPmhUtil.newDateFormat().getTimeZone());
490         c.setTime(d);
491         c.set(Calendar.MILLISECOND, 0);
492         return c.getTime();
493       }
494       case DAY: {
495         final Calendar c = Calendar.getInstance();
496         c.setTimeZone(OaiPmhUtil.newDateFormat().getTimeZone());
497         c.setTime(d);
498         c.set(Calendar.HOUR_OF_DAY, 0);
499         c.set(Calendar.MINUTE, 0);
500         c.set(Calendar.SECOND, 0);
501         c.set(Calendar.MILLISECOND, 0);
502         return c.getTime();
503       }
504       default:
505         return unexhaustiveMatch();
506     }
507   }
508 
509   static class BadArgumentException extends RuntimeException {
510   }
511 
512   /**
513    * Environment for the list verbs ListIdentifiers and ListRecords. Handles the boilerplate of getting, validating and
514    * providing the parameters, creating error responses etc. Call {@link #apply(Params)} to create the XML. Also use
515    * {@link org.opencastproject.oaipmh.server.OaiPmhRepository.ListItemsEnv.ListXmlGen} for the XML generation.
516    */
517   abstract class ListItemsEnv {
518     ListItemsEnv() {
519     }
520 
521     /** Create the regular response from the given parameters. */
522     protected abstract ListXmlGen respond(ListGenParams params);
523 
524     /** Call this method to create the XML. */
525     public XmlGen apply(final Params p) {
526       // check parameters
527       if (p.getSet().isPresent() && sets.isEmpty()) {
528         return createNoSetHierarchyResponse(p);
529       }
530       final boolean resumptionTokenExists = p.getResumptionToken().isPresent();
531       final boolean otherParamExists = p.getMetadataPrefix().isPresent() || p.getFrom().isPresent()
532           || p.getUntil().isPresent() || p.getSet().isPresent();
533 
534       if (resumptionTokenExists && otherParamExists || !resumptionTokenExists && !otherParamExists) {
535         return createBadArgumentResponse(p);
536       }
537       final Optional<Date> from = p.getFrom().map(Functions::asDate).map(d -> granulate(d));
538       final Function<Date, Date> untilAdjustment = getRepositoryTimeGranularity() == Granularity.DAY
539           ? addDay(1)
540           : Function.identity();
541       final Optional<Date> untilGranularity = p.getUntil()
542           .map(Functions::asDate)
543           .map(d -> granulate(d))
544           .map(untilAdjustment::apply);
545       if (from.isPresent() && untilGranularity.isPresent()) {
546         Date fromDate = from.get();
547         Date untilDate = untilGranularity.get();
548         if (!fromDate.before(untilDate)) {
549           return createBadArgumentResponse(p);
550         }
551       }
552       if (otherParamExists && p.getMetadataPrefix().isEmpty()) {
553         return createBadArgumentResponse(p);
554       }
555       // <- params are ok
556 
557       final Optional<Date> until = Optional.of(untilGranularity.orElseGet(() -> currentDate()));
558 
559       final String metadataPrefix = p.getResumptionToken()
560           .flatMap(t -> getMetadataPrefixFromToken(t))
561           .orElseGet(getMetadataPrefix(p));
562 
563       final List<MetadataProvider> metadataProviders;
564       if (p.getResumptionToken().isPresent()) {
565         metadataProviders = getMetadataProviderFromToken.apply(p.getResumptionToken().get())
566             .map(Collections::singletonList)
567             .orElseGet(Collections::emptyList);
568       } else {
569         metadataProviders = Collections.singletonList(getMetadataProvider(metadataPrefix).orElseThrow(() ->
570             new IllegalStateException("No MetadataProvider found for fallback")
571         ));
572       }
573 
574       for (MetadataProvider metadataProvider : metadataProviders) {
575         try {
576           final SearchResult result;
577           @SuppressWarnings("unchecked")
578           final Optional<String>[] set = new Optional[]{p.getSet()};
579 
580           if (!resumptionTokenExists) {
581             // start a new query
582             if (p.getSet().isPresent() && !sets.stream().anyMatch(
583                 setDef -> StringUtils.equals(setDef.getSetSpec(), p.getSet().get()))) {
584               // If there is no set specification, immediately return a no result response
585               return createNoRecordsMatchResponse(p);
586             }
587             result = getPersistence().search(
588                 queryRepo(getRepositoryId())
589                     .setDefinitions(sets)
590                     .setSpec(p.getSet().orElse(null))
591                     .modifiedAfter(from)
592                     .modifiedBefore(until)
593                     .limit(getResultLimit()).build());
594           } else {
595             // resume query
596             ResumableQuery rq = getSavedQuery(p.getResumptionToken().get())
597                 .orElseThrow(BadResumptionTokenException::new);
598             set[0] = rq.getSet();
599             result = getPersistence().search(
600                 queryRepo(getRepositoryId())
601                     .setDefinitions(sets)
602                     .setSpec(rq.getSet().orElse(null))
603                     .modifiedAfter(rq.getLastResult())
604                     .modifiedBefore(rq.getUntil())
605                     .limit(getResultLimit())
606                     .subsequentRequest(true).build());
607           }
608 
609           if (result.size() > 0) {
610             return respond(new ListGenParams(
611                 OaiPmhRepository.this,
612                 result,
613                 metadataProvider,
614                 metadataPrefix,
615                 p.getResumptionToken(),
616                 from,
617                 until.get(),
618                 set[0],
619                 p));
620           } else {
621             return createNoRecordsMatchResponse(p);
622           }
623 
624         } catch (BadResumptionTokenException e) {
625           return createBadResumptionTokenResponse(p);
626         }
627       }
628       // no metadata provider found
629       return createCannotDisseminateFormatResponse(p);
630     }
631 
632     /** Get a metadata prefix from a resumption token. */
633     private Optional<String> getMetadataPrefixFromToken(String token) {
634       return getSavedQuery(token).map(resumableQuery -> resumableQuery.getMetadataPrefix());
635     }
636 
637     /** Get a metadata provider from a resumption token. */
638     private final Function<String, Optional<MetadataProvider>> getMetadataProviderFromToken =
639         new Function<String, Optional<MetadataProvider>>() {
640       @Override
641       public Optional<MetadataProvider> apply(String token) {
642         return getSavedQuery(token).flatMap(resumableQuery -> getMetadataProvider(resumableQuery.getMetadataPrefix()));
643       }
644     };
645 
646     /** Get the metadata prefix lazily. */
647     private Supplier<String> getMetadataPrefix(final Params p) {
648       return () -> {
649         try {
650           return p.getMetadataPrefix()
651               .orElse(OaiPmhConstants.OAI_DC_METADATA_FORMAT.getPrefix());
652         } catch (Exception e) {
653           return chuck(e);
654         }
655       };
656     }
657 
658     /** OAI XML response generation environment for list responses. */
659     abstract class ListXmlGen extends OaiVerbXmlGen {
660 
661       protected final ListGenParams params;
662 
663       ListXmlGen(ListGenParams p) {
664         super(p.getRepository(), p.getParams());
665         this.params = p;
666       }
667 
668       /** Implement to create your content. Gets placed as children of the verb node. */
669       protected abstract List<Node> createContent(Optional<String> set);
670 
671       @Override
672       public Element create() {
673         final List<Node> content = new ArrayList<Node>(createContent(params.getSet()));
674         if (content.size() == 0) {
675           return createNoRecordsMatchResponse(params.getParams()).create();
676         }
677         content.add(resumptionToken(params.getResumptionToken(), params.getMetadataPrefix(), params.getResult(),
678                                     params.getUntil(), params.getSet()));
679         return oai(
680                 request($a("metadataPrefix", params.getMetadataPrefix()),
681                         $aSome("from", params.getFrom().map(d -> toSupportedGranularity(d))),
682                         $aSome("until", Optional.of(toSupportedGranularity(params.getUntil()))),
683                         $aSome("set", params.getSet())), verb(content));
684       }
685     }
686 
687     private class BadResumptionTokenException extends RuntimeException {
688     }
689   }
690 }
691 
692 /** Parameter holder for the list generator. */
693 final class ListGenParams {
694   private final OaiPmhRepository repository;
695   private final SearchResult result;
696   private final MetadataProvider metadataProvider;
697   private final String metadataPrefix;
698   private final Optional<String> resumptionToken;
699   private final Optional<Date> from;
700   private final Date until;
701   private final Optional<String> set;
702   private final Params params;
703 
704   // CHECKSTYLE:OFF
705   ListGenParams(OaiPmhRepository repository,
706                 SearchResult result, MetadataProvider metadataProvider,
707                 String metadataPrefix, Optional<String> resumptionToken,
708                 Optional<Date> from, Date until,
709                 Optional<String> set,
710                 Params params) {
711     this.repository = repository;
712     this.result = result;
713     this.metadataProvider = metadataProvider;
714     this.resumptionToken = resumptionToken;
715     this.metadataPrefix = metadataPrefix;
716     this.from = from;
717     this.until = until;
718     this.set = set;
719     this.params = params;
720   }
721   // CHECKSTYLE:ON
722 
723   public OaiPmhRepository getRepository() {
724     return repository;
725   }
726 
727   public SearchResult getResult() {
728     return result;
729   }
730 
731   public MetadataProvider getMetadataProvider() {
732     return metadataProvider;
733   }
734 
735   public Optional<String> getResumptionToken() {
736     return resumptionToken;
737   }
738 
739   public String getMetadataPrefix() {
740     return metadataPrefix;
741   }
742 
743   public Optional<Date> getFrom() {
744     return from;
745   }
746 
747   public Date getUntil() {
748     return until;
749   }
750 
751   public Optional<String> getSet() {
752     return set;
753   }
754 
755   /** The request parameters. */
756   public Params getParams() {
757     return params;
758   }
759 }