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.extension.base;
21  
22  import com.fasterxml.jackson.core.JsonFactory;
23  import com.fasterxml.jackson.core.JsonGenerator;
24  import com.fasterxml.jackson.databind.ObjectMapper;
25  import com.google.common.base.Charsets;
26  import com.google.common.collect.BiMap;
27  import com.google.common.collect.ImmutableBiMap;
28  import com.google.common.collect.ImmutableMap;
29  import com.google.common.collect.Lists;
30  import com.tinkerpop.blueprints.Vertex;
31  import com.tinkerpop.frames.FramedGraph;
32  import com.tinkerpop.frames.FramedGraphFactory;
33  import com.tinkerpop.frames.modules.javahandler.JavaHandlerModule;
34  import eu.ehri.extension.errors.MissingOrInvalidUser;
35  import eu.ehri.project.acl.AnonymousAccessor;
36  import eu.ehri.project.api.Api;
37  import eu.ehri.project.api.ApiFactory;
38  import eu.ehri.project.api.QueryApi;
39  import eu.ehri.project.core.GraphManager;
40  import eu.ehri.project.core.GraphManagerFactory;
41  import eu.ehri.project.core.Tx;
42  import eu.ehri.project.core.TxGraph;
43  import eu.ehri.project.core.impl.TxNeo4jGraph;
44  import eu.ehri.project.definitions.Entities;
45  import eu.ehri.project.exceptions.ItemNotFound;
46  import eu.ehri.project.exceptions.SerializationError;
47  import eu.ehri.project.models.UserProfile;
48  import eu.ehri.project.models.base.Accessible;
49  import eu.ehri.project.models.base.Accessor;
50  import eu.ehri.project.models.base.Actioner;
51  import eu.ehri.project.models.base.Entity;
52  import eu.ehri.project.persistence.Serializer;
53  import org.neo4j.graphdb.GraphDatabaseService;
54  import org.slf4j.Logger;
55  import org.slf4j.LoggerFactory;
56  
57  import javax.ws.rs.core.CacheControl;
58  import javax.ws.rs.core.Context;
59  import javax.ws.rs.core.HttpHeaders;
60  import javax.ws.rs.core.MediaType;
61  import javax.ws.rs.core.Request;
62  import javax.ws.rs.core.Response;
63  import javax.ws.rs.core.StreamingOutput;
64  import javax.ws.rs.core.UriInfo;
65  import java.io.UnsupportedEncodingException;
66  import java.net.URI;
67  import java.net.URLDecoder;
68  import java.nio.charset.StandardCharsets;
69  import java.util.Collection;
70  import java.util.List;
71  import java.util.Map;
72  import java.util.Optional;
73  import java.util.function.Supplier;
74  
75  
76  /**
77   * Base class for web service resources.
78   */
79  public abstract class AbstractResource implements TxCheckedResource {
80  
81      public static final int DEFAULT_LIST_LIMIT = QueryApi.DEFAULT_LIMIT;
82      public static final int ITEM_CACHE_TIME = 60 * 5; // 5 minutes
83  
84      public static final String RESOURCE_ENDPOINT_PREFIX = "classes";
85  
86      protected static final ObjectMapper jsonMapper = new ObjectMapper();
87      protected static final JsonFactory jsonFactory = jsonMapper.getFactory();
88  
89      protected static final Logger logger = LoggerFactory.getLogger(AbstractResource.class);
90      private static final FramedGraphFactory graphFactory = new FramedGraphFactory(new JavaHandlerModule());
91  
92      public static final String CSV_MEDIA_TYPE = "text/csv";
93  
94      /**
95       * RDF Mimetypes and formatting mappings
96       */
97      public final static String TURTLE_MIMETYPE = "text/turtle";
98      public final static String RDF_XML_MIMETYPE = "application/rdf+xml";
99      public final static String N3_MIMETYPE = "application/n-triples";
100     protected final BiMap<String, String> RDF_MIMETYPE_FORMATS = ImmutableBiMap.of(
101             N3_MIMETYPE, "N3",
102             TURTLE_MIMETYPE, "TTL",
103             RDF_XML_MIMETYPE, "RDF/XML"
104     );
105 
106     /**
107      * Query arguments.
108      */
109     public static final String SORT_PARAM = "sort";
110     public static final String FILTER_PARAM = "filter";
111     public static final String LIMIT_PARAM = "limit";
112     public static final String OFFSET_PARAM = "offset";
113     public static final String ACCESSOR_PARAM = "accessibleTo";
114     public static final String GROUP_PARAM = "group";
115     public static final String ALL_PARAM = "all";
116     public static final String ID_PARAM = "id";
117     public static final String LOG_PARAM = "log";
118     public static final String VERSION_PARAM = "version";
119     public static final String SCOPE_PARAM = "scope";
120     public static final String TOLERANT_PARAM = "tolerant";
121     public static final String COMMIT_PARAM = "commit";
122 
123     /**
124      * Serialization config parameters.
125      */
126     public static final String INCLUDE_PROPS_PARAM = "_ip";
127 
128     /**
129      * Header names
130      */
131     public static final String RANGE_HEADER_NAME = "Content-Range";
132     public static final String PATCH_HEADER_NAME = "X-Patch";
133     public static final String AUTH_HEADER_NAME = "X-User";
134     public static final String LOG_MESSAGE_HEADER_NAME = "X-LogMessage";
135     public static final String STREAM_HEADER_NAME = "X-Stream";
136 
137 
138     /**
139      * With each request the headers of that request are injected into the
140      * requestHeaders parameter.
141      */
142     @Context
143     protected HttpHeaders requestHeaders;
144 
145     @Context
146     protected Request request;
147 
148     /**
149      * With each request URI info is injected into the uriInfo parameter.
150      */
151     @Context
152     protected UriInfo uriInfo;
153 
154     protected final FramedGraph<? extends TxGraph> graph;
155     protected final GraphManager manager;
156     private final Serializer serializer;
157 
158     /**
159      * Constructer.
160      *
161      * @param database A Neo4j graph database
162      */
163     public AbstractResource(@Context GraphDatabaseService database) {
164         graph = graphFactory.create(new TxNeo4jGraph(database));
165         manager = GraphManagerFactory.getInstance(graph);
166         serializer = new Serializer.Builder(graph).build();
167     }
168 
169     public FramedGraph<? extends TxGraph> getGraph() {
170         return graph;
171     }
172 
173     /**
174      * Open a transaction on the graph.
175      *
176      * @return the transaction object
177      */
178     protected Tx beginTx() {
179         return graph.getBaseGraph().beginTx();
180     }
181 
182     /**
183      * Get a serializer according to passed-in serialization config.
184      * <p>
185      * Currently the only parameter is <code>_ip=[propertyName]</code> which
186      * ensures a given property is always included in the output.
187      *
188      * @return a vertex serializer
189      */
190     protected Serializer getSerializer() {
191         Optional<List<String>> includeProps = Optional.ofNullable(uriInfo.getQueryParameters(true)
192                 .get(INCLUDE_PROPS_PARAM));
193         return includeProps.isPresent()
194                 ? serializer.withIncludedProperties(includeProps.get())
195                 : serializer;
196     }
197 
198     /**
199      * Get a list of values for a given query parameter key.
200      *
201      * @param key the parameter name
202      * @return a list of string values
203      */
204     protected List<String> getStringListQueryParam(String key) {
205         List<String> value = uriInfo.getQueryParameters().get(key);
206         return value == null ? Lists.<String>newArrayList() : value;
207     }
208 
209     /**
210      * Get an integer value for a given query parameter, falling back
211      * on a default.
212      *
213      * @param key          the parameter name
214      * @param defaultValue the default value
215      * @return an integer value
216      */
217     protected int getIntQueryParam(String key, int defaultValue) {
218         String value = uriInfo.getQueryParameters().getFirst(key);
219         try {
220             return Integer.parseInt(value);
221         } catch (Exception e) {
222             return defaultValue;
223         }
224     }
225 
226     /**
227      * Get a query object configured according to incoming parameters.
228      *
229      * @return a query object
230      */
231     protected QueryApi getQuery() {
232         return api().query()
233                 .setOffset(getIntQueryParam(OFFSET_PARAM, 0))
234                 .setLimit(getIntQueryParam(LIMIT_PARAM, DEFAULT_LIST_LIMIT))
235                 .filter(getStringListQueryParam(FILTER_PARAM))
236                 .orderBy(getStringListQueryParam(SORT_PARAM))
237                 .setStream(isStreaming());
238     }
239 
240     /**
241      * Retrieve the account of the current user, who may be
242      * anonymous.
243      *
244      * @return The UserProfile
245      */
246     protected Accessor getRequesterUserProfile() {
247         Optional<String> id = getRequesterIdentifier();
248         if (!id.isPresent()) {
249             return AnonymousAccessor.getInstance();
250         } else {
251             try {
252                 return manager.getEntity(id.get(), Accessor.class);
253             } catch (ItemNotFound e) {
254                 throw new MissingOrInvalidUser(id.get());
255             }
256         }
257     }
258 
259     /**
260      * Fetch an instance of the API with the current user.
261      *
262      * @return an Api instance
263      */
264     protected Api api() {
265         return ApiFactory.withLogging(graph, getRequesterUserProfile());
266     }
267 
268     /**
269      * Fetch an instance of the API for anonymous access.
270      *
271      * @return an Api instance
272      */
273     protected Api anonymousApi() {
274         return ApiFactory.noLogging(graph, AnonymousAccessor.getInstance());
275     }
276 
277     /**
278      * Retrieve the profile of the current user, throwing a
279      * BadRequest if it's invalid or not a user.
280      *
281      * @return the current user profile
282      */
283     protected UserProfile getCurrentUser() {
284         Accessor profile = getRequesterUserProfile();
285         if (profile.isAdmin() || profile.isAnonymous()
286                 || !profile.getType().equals(Entities.USER_PROFILE)) {
287             throw new MissingOrInvalidUser(profile.getId());
288         }
289         return profile.as(UserProfile.class);
290     }
291 
292     /**
293      * Retrieve the current actioner, which may be a user or
294      * a group, throwing a bad request if it's invalid.
295      *
296      * @return an actioner frame
297      */
298     protected Actioner getCurrentActioner() {
299         return getRequesterUserProfile().as(Actioner.class);
300     }
301 
302     /**
303      * Retrieve an action log message from the request header.
304      *
305      * @return An optional log message
306      */
307     protected Optional<String> getLogMessage() {
308         List<String> list = requestHeaders.getRequestHeader(LOG_MESSAGE_HEADER_NAME);
309         if (list != null && !list.isEmpty()) {
310             try {
311                 return Optional.of(URLDecoder.decode(list.get(0), StandardCharsets.UTF_8.name()));
312             } catch (UnsupportedEncodingException e) {
313                 logger.error("Unsupported encoding in header: {}", e);
314                 return Optional.empty();
315             }
316         }
317         return Optional.empty();
318     }
319 
320     /**
321      * Determine if a PATCH header is present. Ideally, Jersey would
322      * support the HTTP PATCH method, but it doesn't so we have to
323      * user a header.
324      *
325      * @return Patch is given
326      */
327     protected Boolean isPatch() {
328         List<String> list = requestHeaders.getRequestHeader(PATCH_HEADER_NAME);
329         if (list != null && !list.isEmpty()) {
330             return Boolean.valueOf(list.get(0));
331         }
332         return false;
333     }
334 
335     /**
336      * Determine if the X-Stream header is present. This changes
337      * the semantics of paged results so that no full count is
338      * fetched (making it more efficient.)
339      *
340      * @return Patch is given
341      */
342     protected boolean isStreaming() {
343         List<String> list = requestHeaders.getRequestHeader(STREAM_HEADER_NAME);
344         if (list != null && !list.isEmpty()) {
345             return Boolean.valueOf(list.get(0));
346         }
347         return false;
348     }
349 
350     /**
351      * Retrieve the id string of the requester's user profile.
352      *
353      * @return the user's id, if present
354      */
355     private Optional<String> getRequesterIdentifier() {
356         List<String> list = requestHeaders.getRequestHeader(AUTH_HEADER_NAME);
357         if (list != null && !list.isEmpty()) {
358             return Optional.ofNullable(list.get(0));
359         }
360         return Optional.empty();
361     }
362 
363     /**
364      * Return a default response from a single frame item.
365      *
366      * @param item the item
367      * @param <T>  the item's generic type
368      * @return a serialized representation, with location and cache control
369      * headers.
370      */
371     protected <T extends Entity> Response single(T item) {
372         try {
373             return Response.status(Response.Status.OK)
374                     .entity(getSerializer().entityToJson(item).getBytes(Charsets.UTF_8))
375                     .location(getItemUri(item))
376                     .cacheControl(getCacheControl(item)).build();
377         } catch (SerializationError e) {
378             throw new RuntimeException(e);
379         }
380     }
381 
382     /**
383      * Stream a single page with total, limit, and offset info.
384      *
385      * @param page A page of data
386      * @return A streaming response
387      */
388     protected <T extends Entity> Response streamingPage(Supplier<QueryApi.Page<T>> page) {
389         return streamingPage(page, getSerializer());
390     }
391 
392     /**
393      * Stream a single page with total, limit, and offset info, using
394      * the given entity converter.
395      *
396      * @param page       a page of data
397      * @param serializer a custom serializer instance
398      * @return A streaming response
399      */
400     protected <T extends Entity> Response streamingPage(
401             final Supplier<QueryApi.Page<T>> page, final Serializer serializer) {
402         return streamingList(() -> page.get().getIterable(), serializer,
403                 streamingResponseBuilder(page.get()));
404     }
405 
406     /**
407      * Stream an iterable of vertices.
408      *
409      * @param vertices an iterable of vertices
410      * @return a streaming response
411      */
412     protected Response streamingVertexList(Supplier<Iterable<Vertex>> vertices) {
413         return streamingVertexList(vertices, getSerializer());
414     }
415 
416     /**
417      * Stream an iterable of vertices.
418      *
419      * @param vertices   an iterable of vertices
420      * @param serializer a serializer instance
421      * @return a streaming response
422      */
423     protected Response streamingVertexList(Supplier<Iterable<Vertex>> vertices, Serializer serializer) {
424         return streamingVertexList(vertices, serializer, Response.ok());
425     }
426 
427     /**
428      * Return a streaming response from an iterable.
429      *
430      * @param list A list of framed items
431      * @return A streaming response
432      */
433     protected <T extends Entity> Response streamingList(Supplier<Iterable<T>> list) {
434         return streamingList(list, getSerializer());
435     }
436 
437     /**
438      * Return a streaming response from an iterable of item lists.
439      *
440      * @param lists an iterable of item groups
441      * @return a streaming response
442      */
443     protected <T extends Entity> Response streamingListOfLists(Supplier<Iterable<? extends Collection<T>>> lists) {
444         return streamingGroup(lists, getSerializer(), Response.ok());
445     }
446 
447     /**
448      * Return a streaming response from an iterable, using the given
449      * entity converter.
450      *
451      * @param list A list of framed items
452      * @return A streaming response
453      */
454     protected <T extends Entity> Response streamingList(Supplier<Iterable<T>> list, Serializer serializer) {
455         return streamingList(list, serializer, Response.ok());
456     }
457 
458     /**
459      * Get the URI for a given item.
460      *
461      * @param item The item
462      * @return The resource URI for that item.
463      */
464     protected URI getItemUri(Entity item) {
465         return uriInfo.getBaseUriBuilder()
466                 .path(RESOURCE_ENDPOINT_PREFIX)
467                 .path(item.getType())
468                 .path(item.getId()).build();
469     }
470 
471     /**
472      * Return a response from a new item with a 201 CREATED status.
473      *
474      * @param frame A newly-created item
475      * @return a 201 response with the new item's location set
476      */
477     protected Response creationResponse(Entity frame) {
478         try {
479             return Response.status(Response.Status.CREATED).location(getItemUri(frame))
480                     .entity(getSerializer().entityToJson(frame))
481                     .build();
482         } catch (SerializationError serializationError) {
483             throw new RuntimeException(serializationError);
484         }
485     }
486 
487     /**
488      * Get a cache control header based on the access restrictions
489      * set on the item. If it is restricted, instruct clients not
490      * to cache the response.
491      *
492      * @param item The item
493      * @return A cache control object.
494      */
495     protected <T extends Entity> CacheControl getCacheControl(T item) {
496         CacheControl cc = new CacheControl();
497         if (!(item instanceof Accessible)
498                 || !(((Accessible) item).hasAccessRestriction())) {
499             cc.setMaxAge(ITEM_CACHE_TIME);
500         } else {
501             cc.setNoStore(true);
502             cc.setNoCache(true);
503         }
504         return cc;
505     }
506 
507     /**
508      * Get an RDF format for content-negotiation form
509      *
510      * @param format        a format string, possibly null
511      * @param defaultFormat a default format
512      * @return an RDF format
513      */
514     protected String getRdfFormat(String format, String defaultFormat) {
515         if (format == null) {
516             for (String mimeValue : RDF_MIMETYPE_FORMATS.keySet()) {
517                 MediaType mime = MediaType.valueOf(mimeValue);
518                 if (requestHeaders.getAcceptableMediaTypes().contains(mime)) {
519                     return RDF_MIMETYPE_FORMATS.get(mimeValue);
520                 }
521             }
522             return defaultFormat;
523         } else {
524             return RDF_MIMETYPE_FORMATS.containsValue(format) ? format : defaultFormat;
525         }
526     }
527 
528     private <T> Response.ResponseBuilder streamingResponseBuilder(QueryApi.Page<T> page) {
529         Response.ResponseBuilder builder = Response.ok();
530         for (Map.Entry<String, Object> entry : getHeaders(page).entrySet()) {
531             builder = builder.header(entry.getKey(), entry.getValue());
532         }
533         return builder;
534     }
535 
536     private Map<String, Object> getHeaders(QueryApi.Page<?> page) {
537         return ImmutableMap.<String, Object>of(
538                 RANGE_HEADER_NAME,
539                 String.format("offset=%d; limit=%d; total=%d",
540                         page.getOffset(), page.getLimit(), page.getTotal()));
541     }
542 
543     private Response streamingVertexList(
544             Supplier<Iterable<Vertex>> page, Serializer serializer, Response.ResponseBuilder responseBuilder) {
545         return responseBuilder.entity((StreamingOutput) outputStream -> {
546             final Serializer cacheSerializer = serializer.withCache();
547             try (Tx tx = beginTx();
548                  JsonGenerator g = jsonFactory.createGenerator(outputStream)) {
549                 g.writeStartArray();
550                 for (Vertex item : page.get()) {
551                     g.writeRaw('\n');
552                     jsonMapper.writeValue(g, item == null ? null : cacheSerializer.vertexToData(item));
553                 }
554                 g.writeEndArray();
555                 tx.success();
556             } catch (SerializationError e) {
557                 throw new RuntimeException(e);
558             }
559         }).type(MediaType.APPLICATION_JSON_TYPE).build();
560     }
561 
562     private <T extends Entity> Response streamingList(
563             Supplier<Iterable<T>> page, Serializer serializer, Response.ResponseBuilder responseBuilder) {
564         return responseBuilder.entity((StreamingOutput) outputStream -> {
565             final Serializer cacheSerializer = serializer.withCache();
566             try (Tx tx = beginTx();
567                  JsonGenerator g = jsonFactory.createGenerator(outputStream)) {
568                 g.writeStartArray();
569                 for (T item : page.get()) {
570                     g.writeRaw('\n');
571                     jsonMapper.writeValue(g, item == null ? null : cacheSerializer.entityToData(item));
572                 }
573                 g.writeEndArray();
574                 tx.success();
575             } catch (SerializationError e) {
576                 throw new RuntimeException(e);
577             }
578         }).type(MediaType.APPLICATION_JSON_TYPE).build();
579     }
580 
581     private <T extends Entity> Response streamingGroup(
582             Supplier<Iterable<? extends Collection<T>>> groups, Serializer serializer, Response.ResponseBuilder responseBuilder) {
583         return responseBuilder.entity((StreamingOutput) outputStream -> {
584             final Serializer cacheSerializer = serializer.withCache();
585             try (Tx tx = beginTx();
586                  JsonGenerator g = jsonFactory.createGenerator(outputStream)) {
587                 g.writeStartArray();
588                 for (Collection<T> collect : groups.get()) {
589                     g.writeStartArray();
590                     for (T item : collect) {
591                         jsonMapper.writeValue(g, item == null ? null : cacheSerializer.entityToData(item));
592                     }
593                     g.writeEndArray();
594                     g.writeRaw('\n');
595                 }
596                 g.writeEndArray();
597 
598                 tx.success();
599             } catch (SerializationError e) {
600                 throw new RuntimeException(e);
601             }
602         }).type(MediaType.APPLICATION_JSON_TYPE).build();
603     }
604 }