001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.fcrepo.kernel.modeshape.observer;
019
020import static com.google.common.base.MoreObjects.toStringHelper;
021import static org.fcrepo.kernel.api.observer.EventType.RESOURCE_CREATION;
022import static org.fcrepo.kernel.api.observer.EventType.RESOURCE_DELETION;
023import static org.fcrepo.kernel.api.observer.EventType.RESOURCE_MODIFICATION;
024import static org.fcrepo.kernel.api.observer.EventType.RESOURCE_RELOCATION;
025import static org.fcrepo.kernel.api.observer.EventType.INBOUND_REFERENCE;
026import static org.fcrepo.kernel.modeshape.FedoraJcrConstants.FEDORA_JCR_REFERENCE;
027import static org.fcrepo.kernel.api.observer.OptionalValues.BASE_URL;
028import static org.fcrepo.kernel.api.observer.OptionalValues.USER_AGENT;
029import static javax.jcr.observation.Event.NODE_ADDED;
030import static javax.jcr.observation.Event.NODE_MOVED;
031import static javax.jcr.observation.Event.NODE_REMOVED;
032import static javax.jcr.observation.Event.PROPERTY_ADDED;
033import static javax.jcr.observation.Event.PROPERTY_CHANGED;
034import static javax.jcr.observation.Event.PROPERTY_REMOVED;
035import static org.modeshape.jcr.api.JcrConstants.JCR_CONTENT;
036import static org.slf4j.LoggerFactory.getLogger;
037import static java.time.Instant.ofEpochMilli;
038import static java.util.Arrays.asList;
039import static java.util.Collections.emptyMap;
040import static java.util.Collections.singleton;
041import static java.util.Objects.isNull;
042import static java.util.Objects.requireNonNull;
043import static java.util.UUID.randomUUID;
044import static java.util.stream.Collectors.joining;
045import static java.util.stream.Collectors.toSet;
046import static java.util.stream.Stream.empty;
047
048import java.io.IOException;
049import java.net.URI;
050import java.time.Instant;
051import java.util.Collection;
052import java.util.HashMap;
053import java.util.HashSet;
054import java.util.List;
055import java.util.Map;
056import java.util.Set;
057import java.util.stream.Stream;
058
059import javax.jcr.RepositoryException;
060import javax.jcr.nodetype.NodeType;
061import javax.jcr.observation.Event;
062
063import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
064import org.fcrepo.kernel.api.observer.EventType;
065import org.fcrepo.kernel.api.observer.FedoraEvent;
066import org.fcrepo.kernel.modeshape.identifiers.HashConverter;
067import org.fcrepo.kernel.modeshape.utils.FedoraSessionUserUtil;
068
069import org.slf4j.Logger;
070import com.fasterxml.jackson.databind.ObjectMapper;
071import com.fasterxml.jackson.databind.JsonNode;
072import com.google.common.collect.ImmutableMap;
073
074/**
075 * A very simple abstraction to prevent event-driven machinery downstream from the repository from relying directly
076 * on a JCR interface {@link Event}. Can represent either a single JCR event or several.
077 *
078 * @author ajs6f
079 * @since Feb 19, 2013
080 */
081public class FedoraEventImpl implements FedoraEvent {
082
083    private final static ObjectMapper MAPPER = new ObjectMapper();
084
085    private final static Logger LOGGER = getLogger(FedoraEventImpl.class);
086
087    private final String path;
088    private final String userID;
089    private final URI userURI;
090    private final Instant date;
091    private final Map<String, String> info;
092    private final String eventID;
093    private final Set<String> eventResourceTypes;
094
095    private final Set<EventType> eventTypes = new HashSet<>();
096
097    private static final List<Integer> PROPERTY_TYPES = asList(Event.PROPERTY_ADDED,
098        Event.PROPERTY_CHANGED, Event.PROPERTY_REMOVED, FEDORA_JCR_REFERENCE);
099
100    /**
101     * Create a new FedoraEvent
102     * @param type the Fedora EventType
103     * @param path the node path corresponding to this event
104     * @param resourceTypes the rdf types of the corresponding resource
105     * @param userID the acting user for this event
106     * @param userURI the uri of the acting user for this event
107     * @param date the timestamp for this event
108     * @param info supplementary information
109     */
110    public FedoraEventImpl(final EventType type, final String path, final Set<String> resourceTypes,
111            final String userID, final URI userURI, final Instant date, final Map<String, String> info) {
112        this(singleton(type), path, resourceTypes, userID, userURI, date, info);
113    }
114
115   /**
116     * Create a new FedoraEvent
117     * @param types a collection of Fedora EventTypes
118     * @param path the node path corresponding to this event
119     * @param resourceTypes the rdf types of the corresponding resource
120     * @param userID the acting user for this event
121     * @param userURI the uri of the acting user for this event
122     * @param date the timestamp for this event
123     * @param info supplementary information
124     */
125    public FedoraEventImpl(final Collection<EventType> types, final String path, final Set<String> resourceTypes,
126            final String userID, final URI userURI, final Instant date, final Map<String, String> info) {
127        requireNonNull(types, "FedoraEvent requires a non-null event type");
128        requireNonNull(path, "FedoraEvent requires a non-null path");
129
130        this.eventTypes.addAll(types);
131        this.path = path;
132        this.eventResourceTypes = resourceTypes;
133        this.userID = userID;
134        this.userURI = userURI;
135        this.date = date;
136        this.info = isNull(info) ? emptyMap() : info;
137        this.eventID = "urn:uuid:" + randomUUID().toString();
138    }
139
140
141    /**
142     * @return the event types of the underlying JCR {@link Event}s
143     */
144    @Override
145    public Set<EventType> getTypes() {
146        return eventTypes;
147    }
148
149    /**
150     * @return the RDF types of the underlying Fedora Resource
151    **/
152    @Override
153    public Set<String> getResourceTypes() {
154        return eventResourceTypes;
155    }
156
157    /**
158     * @return the path of the underlying JCR {@link Event}s
159     */
160    @Override
161    public String getPath() {
162        return path;
163    }
164
165    /**
166     * @return the user ID of the underlying JCR {@link Event}s
167     */
168    @Override
169    public String getUserID() {
170        return userID;
171    }
172
173    /**
174     * @return the user URI of the underlying JCR {@link Event}s
175     */
176    @Override
177    public URI getUserURI() {
178        return userURI;
179    }
180
181    /**
182     * @return the date of the FedoraEvent
183     */
184    @Override
185    public Instant getDate() {
186        return date;
187    }
188
189    /**
190     * Get the event ID.
191     * @return Event identifier to use for building event URIs (e.g., in an external triplestore).
192    **/
193    @Override
194    public String getEventID() {
195        return eventID;
196    }
197
198    /**
199     * Return a Map with any additional information about the event.
200     * @return a Map of additional information.
201     */
202    @Override
203    public Map<String, String> getInfo() {
204
205        return info;
206    }
207
208    @Override
209    public String toString() {
210
211        return toStringHelper(this)
212            .add("Event types:", getTypes().stream()
213                            .map(EventType::getName)
214                            .collect(joining(", ")))
215            .add("Event resource types:", String.join(",", eventResourceTypes))
216            .add("Path:", getPath())
217            .add("Date: ", getDate()).toString();
218    }
219
220    private static final Map<Integer, EventType> translation = ImmutableMap.<Integer, EventType>builder()
221            .put(NODE_ADDED, RESOURCE_CREATION)
222            .put(NODE_REMOVED, RESOURCE_DELETION)
223            .put(PROPERTY_ADDED, RESOURCE_MODIFICATION)
224            .put(PROPERTY_REMOVED, RESOURCE_MODIFICATION)
225            .put(PROPERTY_CHANGED, RESOURCE_MODIFICATION)
226            .put(NODE_MOVED, RESOURCE_RELOCATION)
227            .put(FEDORA_JCR_REFERENCE, INBOUND_REFERENCE).build();
228
229    /**
230     * Get the Fedora event type for a JCR type
231     *
232     * @param i the integer value of a JCR type
233     * @return EventType
234     */
235    public static EventType valueOf(final Integer i) {
236        final EventType type = translation.get(i);
237        if (isNull(type)) {
238            throw new IllegalArgumentException("Invalid event type: " + i);
239        }
240        return type;
241    }
242
243
244    /**
245     * Convert a JCR Event to a FedoraEvent
246     * @param event the JCR Event
247     * @return a FedoraEvent
248     */
249    public static FedoraEvent from(final Event event) {
250        requireNonNull(event);
251        try {
252            @SuppressWarnings("unchecked")
253            final Map<String, String> info = new HashMap<>(event.getInfo());
254
255            final String userdata = event.getUserData();
256            try {
257                if (userdata != null && !userdata.isEmpty()) {
258                    final JsonNode json = MAPPER.readTree(userdata);
259                    if (json.has(BASE_URL)) {
260                        String url = json.get(BASE_URL).asText();
261                        while (url.endsWith("/")) {
262                            url = url.substring(0, url.length() - 1);
263                        }
264                        info.put(BASE_URL, url);
265                    }
266                    if (json.has(USER_AGENT)) {
267                        info.put(USER_AGENT, json.get(USER_AGENT).asText());
268                    }
269                } else {
270                    LOGGER.debug("Event UserData is empty!");
271                }
272            } catch (final IOException ex) {
273                LOGGER.warn("Error extracting user data: " + userdata, ex.getMessage());
274            }
275
276            final Set<String> resourceTypes = getResourceTypes(event).collect(toSet());
277
278            return new FedoraEventImpl(valueOf(event.getType()), cleanPath(event), resourceTypes,
279                    event.getUserID(), FedoraSessionUserUtil.getUserURI(event.getUserID()), ofEpochMilli(event
280                            .getDate()), info);
281
282        } catch (final RepositoryException ex) {
283            throw new RepositoryRuntimeException("Error converting JCR Event to FedoraEvent", ex);
284        }
285    }
286
287    /**
288     * Get the RDF Types of the resource corresponding to this JCR Event
289     * @param event the JCR event
290     * @return the types recorded on the resource associated to this event
291     */
292    public static Stream<String> getResourceTypes(final Event event) {
293        if (event instanceof org.modeshape.jcr.api.observation.Event) {
294            try {
295                final org.modeshape.jcr.api.observation.Event modeEvent =
296                        (org.modeshape.jcr.api.observation.Event) event;
297                final Stream.Builder<NodeType> types = Stream.builder();
298                for (final NodeType type : modeEvent.getMixinNodeTypes()) {
299                    types.add(type);
300                }
301                types.add(modeEvent.getPrimaryNodeType());
302                return types.build().map(NodeType::getName);
303            } catch (final RepositoryException e) {
304                throw new RepositoryRuntimeException(e);
305            }
306        }
307        return empty(); // wasn't a ModeShape event, so we have no access to resource types
308    }
309
310    /**
311     * The JCR-based Event::getPath contains some Modeshape artifacts that must be removed or modified in
312     * order to correspond to the public resource path. For example, JCR Events will contain a trailing
313     * /jcr:content for Binaries, a trailing /propName for properties, and /#/ notation for URI fragments.
314     */
315    private static String cleanPath(final Event event) throws RepositoryException {
316        // remove any trailing data for property changes
317        final String path = PROPERTY_TYPES.contains(event.getType()) ?
318            event.getPath().substring(0, event.getPath().lastIndexOf("/")) : event.getPath();
319
320        // reformat any hash URIs and remove any trailing /jcr:content
321        final HashConverter converter = new HashConverter();
322        return converter.reverse().convert(path.replaceAll("/" + JCR_CONTENT, ""));
323    }
324}