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