View Javadoc

1   /*
2    * Copyright 2015 Data Archiving and Networked Services (an institute of
3    * Koninklijke Nederlandse Akademie van Wetenschappen), King's College London,
4    * Georg-August-Universitaet Goettingen Stiftung Oeffentlichen Rechts
5    *
6    * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by
7    * the European Commission - subsequent versions of the EUPL (the "Licence");
8    * You may not use this work except in compliance with the Licence.
9    * You may obtain a copy of the Licence at:
10   *
11   * https://joinup.ec.europa.eu/software/page/eupl
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the Licence is distributed on an "AS IS" basis,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the Licence for the specific language governing
17   * permissions and limitations under the Licence.
18   */
19  
20  package eu.ehri.project.oaipmh;
21  
22  import com.google.common.base.Joiner;
23  import com.google.common.base.Preconditions;
24  import com.google.common.base.Splitter;
25  import com.google.common.collect.ImmutableMultimap;
26  import com.google.common.collect.Maps;
27  import com.google.common.collect.Multimap;
28  import eu.ehri.project.oaipmh.errors.OaiPmhArgumentError;
29  import eu.ehri.project.oaipmh.errors.OaiPmhError;
30  import org.slf4j.Logger;
31  import org.slf4j.LoggerFactory;
32  
33  import java.io.UnsupportedEncodingException;
34  import java.net.URLDecoder;
35  import java.net.URLEncoder;
36  import java.nio.charset.StandardCharsets;
37  import java.util.Base64;
38  import java.util.Collection;
39  import java.util.List;
40  import java.util.Map;
41  import java.util.regex.Matcher;
42  import java.util.regex.Pattern;
43  import java.util.stream.Collectors;
44  
45  /**
46   * Handles validation of OAI-PMH state parameters and
47   * resumption token serialization.
48   */
49  public class OaiPmhState {
50  
51      private static final Logger log = LoggerFactory.getLogger(OaiPmhState.class);
52      private static final Splitter queryStringSplitter = Splitter.on('&');
53      private static final Splitter queryStringArgSplitter = Splitter.on('=');
54      private static final Joiner queryStringJoiner = Joiner.on('&');
55      private static final Joiner queryStringArgJoiner = Joiner.on('=');
56  
57      private static final String OFFSET_PARAM = "offset";
58      private static final String LIMIT_PARAM = "limit";
59  
60      private static final String VERB_PARAM = "verb";
61      private static final String METADATA_PREFIX_PARAM = "metadataPrefix";
62      private static final String IDENTIFIER_PARAM = "identifier";
63      private static final String SET_PARAM = "set";
64      private static final String FROM_PARAM = "from";
65      private static final String UNTIL_PARAM = "until";
66      private static final String RESUMPTION_TOKEN_PARAM = "resumptionToken";
67  
68      private static final Pattern SHORT_TIME_FORMAT = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
69      private static final Pattern LONG_TIME_FORMAT = Pattern.compile("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z");
70  
71  
72      private final int offset;
73      private final int limit;
74      private final Verb verb;
75      private final String identifier;
76      private final MetadataPrefix prefix;
77      private final String setSpec;
78      private final String from;
79      private final String until;
80  
81      private OaiPmhState(int offset, int limit, Verb verb, String identifier,
82              MetadataPrefix prefix, String setSpec, String from, String until) throws OaiPmhError {
83          this.offset = offset;
84          this.limit = limit;
85          this.verb = verb;
86          this.identifier = identifier;
87          this.prefix = prefix;
88          this.setSpec = setSpec;
89          this.from = from;
90          this.until = until;
91  
92          validateState();
93      }
94  
95      private OaiPmhState(Verb verb, String identifier,
96              MetadataPrefix prefix, String setSpec, String from, String until, int defaultLimit)
97              throws OaiPmhError {
98          this(0, defaultLimit, verb, identifier, prefix, setSpec, from, until);
99      }
100 
101     private void validateState() throws OaiPmhArgumentError {
102         // Logical consistency checks
103         if (verb != null && verb.equals(Verb.GetRecord) && identifier == null) {
104             throw new OaiPmhArgumentError("No identifier given for " + verb);
105         }
106         if (verb != null && (verb.equals(Verb.ListIdentifiers) || verb.equals(Verb.ListRecords)
107                 || verb.equals(Verb.GetRecord))
108                 && prefix == null) {
109             throw new OaiPmhArgumentError("No metadataPrefix given for " + verb);
110         }
111         if (from != null && until != null && from.compareTo(until) > 0) {
112             throw new OaiPmhArgumentError("Date 'from' filter must be before 'until'");
113         }
114         if (from != null && until != null && from.length() != until.length()) {
115             throw new OaiPmhArgumentError("Date filters 'from' and 'until' must have the same granularity");
116         }
117         validateTime(FROM_PARAM, from);
118         validateTime(UNTIL_PARAM, until);
119     }
120 
121     private static class Builder {
122         int offset;
123         int limit;
124         Verb verb = Verb.Identify;
125         String identifier = null;
126         MetadataPrefix prefix;
127         String setSpec = null;
128         String from = null;
129         String until = null;
130         final int defaultLimit;
131 
132         Builder(int defaultLimit) {
133             this.defaultLimit = defaultLimit;
134         }
135 
136         OaiPmhState build() throws OaiPmhError {
137             return offset > 0
138                     ? new OaiPmhState(offset, limit, verb, identifier, prefix, setSpec, from, until)
139                     : new OaiPmhState(verb, identifier, prefix, setSpec, from, until, defaultLimit);
140         }
141     }
142 
143     int getOffset() {
144         return offset;
145     }
146 
147     int getLimit() {
148         return limit;
149     }
150 
151     boolean hasLimit() {
152         return limit >= 0;
153     }
154 
155     Verb getVerb() {
156         return verb;
157     }
158 
159     String getIdentifier() {
160         return identifier;
161     }
162 
163     MetadataPrefix getMetadataPrefix() {
164         return prefix;
165     }
166 
167     String getSetSpec() {
168         return setSpec;
169     }
170 
171     String getFrom() {
172         return from;
173     }
174 
175     String getUntil() {
176         return until;
177     }
178 
179     boolean shouldResume(int count) {
180         return limit > 0 && count > offset + limit;
181     }
182 
183     boolean hasResumed() {
184         return offset > 0;
185     }
186 
187     String nextState() {
188         return encodeToken(next());
189     }
190 
191     Map<String, String> toMap() {
192         return mapOf(
193                 VERB_PARAM, verb != null ? verb.name() : null,
194                 IDENTIFIER_PARAM, identifier,
195                 METADATA_PREFIX_PARAM, prefix != null ? prefix.name() : null,
196                 SET_PARAM, setSpec,
197                 FROM_PARAM, from,
198                 UNTIL_PARAM, until
199         );
200     }
201 
202     private OaiPmhState next() {
203         try {
204             return new OaiPmhState(offset + limit, limit,
205                     verb, identifier, prefix, setSpec, from, until);
206         } catch (OaiPmhError error) {
207             // This will never happen since we've validated the
208             // params in the constructor.
209             throw new RuntimeException();
210         }
211     }
212 
213     private ImmutableMultimap<String, String> toParams() {
214         ImmutableMultimap.Builder<String, String> builder = ImmutableMultimap.<String, String>builder()
215                 .put(OFFSET_PARAM, String.valueOf(offset))
216                 .put(LIMIT_PARAM, String.valueOf(limit))
217                 .put(VERB_PARAM, verb.name());
218         if (prefix != null) {
219             builder.put(METADATA_PREFIX_PARAM, prefix.name());
220         }
221         if (from != null) {
222             builder.put(FROM_PARAM, from);
223         }
224         if (until != null) {
225             builder.put(UNTIL_PARAM, until);
226         }
227         if (identifier != null) {
228             builder.put(IDENTIFIER_PARAM, identifier);
229         }
230         if (setSpec != null) {
231             builder.put(SET_PARAM, setSpec);
232         }
233         return builder.build();
234     }
235 
236     public static OaiPmhState parse(String rawParams, int defaultLimit) throws OaiPmhError {
237         ImmutableMultimap<String, String> queryParams = decodeParams(rawParams);
238         checkDuplicateArguments(queryParams);
239         String token = queryParams.containsKey(RESUMPTION_TOKEN_PARAM)
240                 ? queryParams.get(RESUMPTION_TOKEN_PARAM).iterator().next()
241                 : null;
242         if (token != null && !token.trim().isEmpty()) {
243             // Check token is the exclusive argument
244             if (queryParams.size() > 2) {
245                 throw new OaiPmhError(ErrorCode.badResumptionToken,
246                         "Resumption token must be an exclusive argument in addition to the verb");
247             }
248 
249             String tokenQueryString = decodeBase64(token);
250             log.trace("Decoding state: {}", tokenQueryString);
251             return buildState(decodeParams(tokenQueryString), defaultLimit);
252         } else {
253             return buildState(queryParams, defaultLimit);
254         }
255     }
256 
257     private static OaiPmhState buildState(ImmutableMultimap<String, String> queryParams, int defaultLimit) throws OaiPmhError {
258         Builder builder = new Builder(defaultLimit);
259         for (Map.Entry<String, String> entry : queryParams.entries()) {
260             switch (entry.getKey()) {
261                 case VERB_PARAM:
262                     builder.verb = Verb.parse(entry.getValue(), Verb.Identify);
263                     break;
264                 case IDENTIFIER_PARAM:
265                     builder.identifier = entry.getValue();
266                     break;
267                 case METADATA_PREFIX_PARAM:
268                     builder.prefix = MetadataPrefix.parse(entry.getValue(), MetadataPrefix.oai_dc);
269                     break;
270                 case SET_PARAM:
271                     builder.setSpec = entry.getValue();
272                     break;
273                 case FROM_PARAM:
274                     builder.from = entry.getValue();
275                     break;
276                 case UNTIL_PARAM:
277                     builder.until = entry.getValue();
278                     break;
279                 case OFFSET_PARAM:
280                     builder.offset = Integer.parseInt(entry.getValue());
281                     break;
282                 case LIMIT_PARAM:
283                     builder.limit = Integer.parseInt(entry.getValue());
284                     break;
285                 default:
286                     throw new OaiPmhArgumentError("Unexpected argument: " + entry.getKey());
287             }
288         }
289         return builder.build();
290     }
291 
292     // Helpers
293 
294     private static void validateTime(String key, String time) throws OaiPmhArgumentError {
295         if (time != null) {
296             Matcher p1 = SHORT_TIME_FORMAT.matcher(time);
297             Matcher p2 = LONG_TIME_FORMAT.matcher(time);
298             if (!p1.matches() && !p2.matches()) {
299                 throw new OaiPmhArgumentError("Invalid time given for " + key + ": " + time);
300             }
301         }
302     }
303 
304     private static void checkDuplicateArguments(ImmutableMultimap<String, String> queryParams)
305             throws OaiPmhArgumentError {
306         for (Map.Entry<String, Collection<String>> e : queryParams.asMap().entrySet()) {
307             if (e.getValue().size() > 1) {
308                 throw new OaiPmhArgumentError("Duplicate value for parameter " + e.getKey());
309             }
310         }
311     }
312 
313     private static String encodeToken(OaiPmhState state) {
314         String stateParams = encodeParams(state.toParams());
315         log.trace("Encoding state: {}", stateParams);
316         return encodeBase64(stateParams);
317     }
318 
319     private static String encodeParams(Multimap<String, String> params) {
320         List<String> parts = params.entries().stream()
321                 .map(e -> queryStringArgJoiner.join(encodeUrlParam(e.getKey()), encodeUrlParam(e.getValue())))
322                 .collect(Collectors.toList());
323         return queryStringJoiner.join(parts);
324     }
325 
326     private static ImmutableMultimap<String, String> decodeParams(String params) {
327         ImmutableMultimap.Builder<String, String> b = ImmutableMultimap.builder();
328         if (params != null) {
329             queryStringSplitter.splitToList(params).stream()
330                     .map(p -> queryStringArgSplitter.limit(2).splitToList(p))
331                     .filter(p -> p.size() == 2)
332                     .forEach(p -> b.put(decodeUrlParam(p.get(0)), decodeUrlParam(p.get(1))));
333         }
334         return b.build();
335     }
336 
337     private static String decodeUrlParam(String s) {
338         try {
339             return URLDecoder.decode(s, StandardCharsets.UTF_8.name());
340         } catch (UnsupportedEncodingException e) {
341             throw new RuntimeException(e);
342         }
343     }
344 
345     private static String encodeUrlParam(String s) {
346         try {
347             return URLEncoder.encode(s, StandardCharsets.UTF_8.name());
348         } catch (UnsupportedEncodingException e) {
349             throw new RuntimeException(e);
350         }
351     }
352 
353     private static String decodeBase64(String s) throws OaiPmhError {
354         try {
355             return new String(Base64.getDecoder().decode(s));
356         } catch (IllegalArgumentException e) {
357             throw new OaiPmhError(ErrorCode.badResumptionToken, "Invalid resumption token: " + s);
358         }
359     }
360 
361     private static String encodeBase64(String s) {
362         return new String(Base64.getEncoder().encode(s.getBytes(StandardCharsets.UTF_8)));
363     }
364 
365     private static Map<String, String> mapOf(String... items) {
366         Preconditions.checkArgument(items.length % 2 == 0, "Items must be pairs of key/value");
367         Map<String, String> map = Maps.newHashMap();
368         for (int i = 0; i < items.length; i += 2) {
369             map.put((items[i]), items[i + 1]);
370         }
371         return map;
372     }
373 }