1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
47
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
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
208
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
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
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 }