001package de.deepamehta.plugins.workspaces;
002
003import de.deepamehta.plugins.facets.FacetsService;
004import de.deepamehta.plugins.facets.model.FacetValue;
005import de.deepamehta.plugins.topicmaps.TopicmapsService;
006
007import de.deepamehta.core.Association;
008import de.deepamehta.core.AssociationDefinition;
009import de.deepamehta.core.AssociationType;
010import de.deepamehta.core.DeepaMehtaObject;
011import de.deepamehta.core.RelatedTopic;
012import de.deepamehta.core.Topic;
013import de.deepamehta.core.TopicType;
014import de.deepamehta.core.Type;
015import de.deepamehta.core.model.ChildTopicsModel;
016import de.deepamehta.core.model.SimpleValue;
017import de.deepamehta.core.model.TopicModel;
018import de.deepamehta.core.osgi.PluginActivator;
019import de.deepamehta.core.service.Cookies;
020import de.deepamehta.core.service.Directives;
021import de.deepamehta.core.service.Inject;
022import de.deepamehta.core.service.ResultList;
023import de.deepamehta.core.service.Transactional;
024import de.deepamehta.core.service.accesscontrol.SharingMode;
025import de.deepamehta.core.service.event.IntroduceAssociationTypeListener;
026import de.deepamehta.core.service.event.IntroduceTopicTypeListener;
027import de.deepamehta.core.service.event.PostCreateAssociationListener;
028import de.deepamehta.core.service.event.PostCreateTopicListener;
029import de.deepamehta.core.storage.spi.DeepaMehtaTransaction;
030
031import javax.ws.rs.GET;
032import javax.ws.rs.POST;
033import javax.ws.rs.PUT;
034import javax.ws.rs.Consumes;
035import javax.ws.rs.Path;
036import javax.ws.rs.PathParam;
037import javax.ws.rs.Produces;
038import javax.ws.rs.core.Context;
039
040import java.util.Iterator;
041import java.util.concurrent.Callable;
042import java.util.logging.Logger;
043
044
045
046@Path("/workspace")
047@Consumes("application/json")
048@Produces("application/json")
049public class WorkspacesPlugin extends PluginActivator implements WorkspacesService, IntroduceTopicTypeListener,
050                                                                                    IntroduceAssociationTypeListener,
051                                                                                    PostCreateTopicListener,
052                                                                                    PostCreateAssociationListener {
053
054    // ------------------------------------------------------------------------------------------------------- Constants
055
056    // Property URIs
057    private static final String PROP_WORKSPACE_ID = "dm4.workspaces.workspace_id";
058
059    // ---------------------------------------------------------------------------------------------- Instance Variables
060
061    @Inject
062    private FacetsService facetsService;
063
064    @Inject
065    private TopicmapsService topicmapsService;
066
067    private Logger logger = Logger.getLogger(getClass().getName());
068
069    // -------------------------------------------------------------------------------------------------- Public Methods
070
071
072
073    // ****************************************
074    // *** WorkspacesService Implementation ***
075    // ****************************************
076
077
078
079    @POST
080    @Path("/{name}/{uri:[^/]*?}/{sharing_mode_uri}")    // Note: default is [^/]+?     // +? is a "reluctant" quantifier
081    @Transactional
082    @Override
083    public Topic createWorkspace(@PathParam("name") final String name, @PathParam("uri") final String uri,
084                                 @PathParam("sharing_mode_uri") final SharingMode sharingMode) {
085        final String operation = "Creating workspace \"" + name + "\" ";
086        final String info = "(uri=\"" + uri + "\", sharingMode=" + sharingMode + ")";
087        try {
088            // We suppress standard workspace assignment here as 1) a workspace itself gets no assignment at all,
089            // and 2) the workspace's default topicmap requires a special assignment. See step 2) below.
090            return dms.getAccessControl().runWithoutWorkspaceAssignment(new Callable<Topic>() {
091                @Override
092                public Topic call() {
093                    logger.info(operation + info);
094                    //
095                    // 1) create workspace
096                    Topic workspace = dms.createTopic(
097                        new TopicModel(uri, "dm4.workspaces.workspace", new ChildTopicsModel()
098                            .put("dm4.workspaces.name", name)
099                            .putRef("dm4.workspaces.sharing_mode", sharingMode.getUri())));
100                    //
101                    // 2) create default topicmap and assign to workspace
102                    Topic topicmap = topicmapsService.createTopicmap(TopicmapsService.DEFAULT_TOPICMAP_NAME,
103                        TopicmapsService.DEFAULT_TOPICMAP_RENDERER);
104                    // Note: user <anonymous> has no READ access to the workspace just created as it has no owner.
105                    // So we must use the privileged assignToWorkspace() call here. This is to support the
106                    // "DM4 Sign-up" 3rd-party plugin.
107                    dms.getAccessControl().assignToWorkspace(topicmap, workspace.getId());
108                    //
109                    return workspace;
110                }
111            });
112        } catch (Exception e) {
113            throw new RuntimeException(operation + "failed " + info, e);
114        }
115    }
116
117    // ---
118
119    // Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
120    @GET
121    @Path("/{uri}")
122    @Override
123    public Topic getWorkspace(@PathParam("uri") String uri) {
124        return dms.getAccessControl().getWorkspace(uri);
125    }
126
127    // Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
128    @GET
129    @Path("/{id}/topics/{type_uri}")
130    @Override
131    public ResultList<RelatedTopic> getAssignedTopics(@PathParam("id") long workspaceId,
132                                                      @PathParam("type_uri") String topicTypeUri) {
133        ResultList<RelatedTopic> topics = dms.getTopics(topicTypeUri, 0);   // maxResultSize=0
134        applyWorkspaceFilter(topics.iterator(), workspaceId);
135        return topics;
136    }
137
138    // Note: the "include_childs" query paramter is handled by the core's JerseyResponseFilter
139    @GET
140    @Path("/object/{id}")
141    @Override
142    public Topic getAssignedWorkspace(@PathParam("id") long objectId) {
143        long workspaceId = getAssignedWorkspaceId(objectId);
144        if (workspaceId == -1) {
145            return null;
146        }
147        return dms.getTopic(workspaceId);
148    }
149
150    @Override
151    public boolean isAssignedToWorkspace(long objectId, long workspaceId) {
152        return getAssignedWorkspaceId(objectId) == workspaceId;
153    }
154
155    // ---
156
157    // Note: part of REST API, not part of OSGi service
158    @PUT
159    @Path("/{workspace_id}/object/{object_id}")
160    @Transactional
161    public Directives assignToWorkspace(@PathParam("object_id") long objectId,
162                                        @PathParam("workspace_id") long workspaceId) {
163        assignToWorkspace(dms.getObject(objectId), workspaceId);
164        return Directives.get();
165    }
166
167    @Override
168    public void assignToWorkspace(DeepaMehtaObject object, long workspaceId) {
169        checkArgument(workspaceId);
170        _assignToWorkspace(object, workspaceId);
171    }
172
173    @Override
174    public void assignTypeToWorkspace(Type type, long workspaceId) {
175        assignToWorkspace(type, workspaceId);
176        // view config topics
177        for (Topic configTopic : type.getViewConfig().getConfigTopics()) {
178            _assignToWorkspace(configTopic, workspaceId);
179        }
180        // association definitions
181        for (AssociationDefinition assocDef : type.getAssocDefs()) {
182            _assignToWorkspace(assocDef, workspaceId);
183            // view config topics (of association definition)
184            for (Topic configTopic : assocDef.getViewConfig().getConfigTopics()) {
185                _assignToWorkspace(configTopic, workspaceId);
186            }
187        }
188    }
189
190
191
192    // ********************************
193    // *** Listener Implementations ***
194    // ********************************
195
196
197
198    /**
199     * Takes care the DeepaMehta standard types (and their parts) get an assignment to the DeepaMehta workspace.
200     * This is important in conjunction with access control.
201     * Note: type introduction is aborted if at least one of these conditions apply:
202     *     - A workspace cookie is present. In this case the type gets its workspace assignment the regular way (this 
203     *       plugin's post-create listeners). This happens e.g. when a type is created interactively in the Webclient.
204     *     - The type is not a DeepaMehta standard type. In this case the 3rd-party plugin developer is responsible
205     *       for doing the workspace assignment (in case the type is created programmatically while a migration).
206     *       DM can't know to which workspace a 3rd-party type belongs to. A type is regarded a DeepaMehta standard
207     *       type if its URI begins with "dm4."
208     */
209    @Override
210    public void introduceTopicType(TopicType topicType) {
211        long workspaceId = workspaceIdForType(topicType);
212        if (workspaceId == -1) {
213            return;
214        }
215        //
216        assignTypeToWorkspace(topicType, workspaceId);
217    }
218
219    /**
220     * Takes care the DeepaMehta standard types (and their parts) get an assignment to the DeepaMehta workspace.
221     * This is important in conjunction with access control.
222     * Note: type introduction is aborted if at least one of these conditions apply:
223     *     - A workspace cookie is present. In this case the type gets its workspace assignment the regular way (this 
224     *       plugin's post-create listeners). This happens e.g. when a type is created interactively in the Webclient.
225     *     - The type is not a DeepaMehta standard type. In this case the 3rd-party plugin developer is responsible
226     *       for doing the workspace assignment (in case the type is created programmatically while a migration).
227     *       DM can't know to which workspace a 3rd-party type belongs to. A type is regarded a DeepaMehta standard
228     *       type if its URI begins with "dm4."
229     */
230    @Override
231    public void introduceAssociationType(AssociationType assocType) {
232        long workspaceId = workspaceIdForType(assocType);
233        if (workspaceId == -1) {
234            return;
235        }
236        //
237        assignTypeToWorkspace(assocType, workspaceId);
238    }
239
240    // ---
241
242    /**
243     * Assigns every created topic to the current workspace.
244     */
245    @Override
246    public void postCreateTopic(Topic topic) {
247        if (workspaceAssignmentIsSuppressed(topic)) {
248            return;
249        }
250        // Note: we must avoid a vicious circle that would occur when editing a workspace. A Description topic
251        // would be created (as no description is set when the workspace is created) and be assigned to the
252        // workspace itself. This would create an endless recursion while bubbling the modification timestamp.
253        if (isWorkspaceDescription(topic)) {
254            return;
255        }
256        //
257        long workspaceId = workspaceId();
258        // Note: when there is no current workspace (because no user is logged in) we do NOT fallback to assigning
259        // the DeepaMehta workspace. This would not help in gaining data consistency because the topics created
260        // so far (BEFORE the Workspaces plugin is activated) would still have no workspace assignment.
261        // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
262        // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
263        // the other hand we don't have such a mechanism (and don't want one either).
264        if (workspaceId == -1) {
265            return;
266        }
267        //
268        assignToWorkspace(topic, workspaceId);
269    }
270
271    /**
272     * Assigns every created association to the current workspace.
273     */
274    @Override
275    public void postCreateAssociation(Association assoc) {
276        if (workspaceAssignmentIsSuppressed(assoc)) {
277            return;
278        }
279        // Note: we must avoid a vicious circle that would occur when the association is an workspace assignment.
280        if (isWorkspaceAssignment(assoc)) {
281            return;
282        }
283        //
284        long workspaceId = workspaceId();
285        // Note: when there is no current workspace (because no user is logged in) we do NOT fallback to assigning
286        // the DeepaMehta workspace. This would not help in gaining data consistency because the associations created
287        // so far (BEFORE the Workspaces plugin is activated) would still have no workspace assignment.
288        // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
289        // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
290        // the other hand we don't have such a mechanism (and don't want one either).
291        if (workspaceId == -1) {
292            return;
293        }
294        //
295        assignToWorkspace(assoc, workspaceId);
296    }
297
298
299
300    // ------------------------------------------------------------------------------------------------- Private Methods
301
302    private long workspaceId() {
303        Cookies cookies = Cookies.get();
304        if (!cookies.has("dm4_workspace_id")) {
305            return -1;
306        }
307        return cookies.getLong("dm4_workspace_id");
308    }
309
310    /**
311     * Returns the ID of the DeepaMehta workspace or -1 to signal abortion of type introduction.
312     */
313    private long workspaceIdForType(Type type) {
314        return workspaceId() == -1 && isDeepaMehtaStandardType(type) ? getDeepaMehtaWorkspace().getId() : -1;
315    }
316
317    // ---
318
319    // ### TODO: copy in AccessControlImpl.java
320    private long getAssignedWorkspaceId(long objectId) {
321        return dms.hasProperty(objectId, PROP_WORKSPACE_ID) ? (Long) dms.getProperty(objectId, PROP_WORKSPACE_ID) : -1;
322    }
323
324    private void _assignToWorkspace(DeepaMehtaObject object, long workspaceId) {
325        try {
326            // 1) create assignment association
327            facetsService.updateFacet(object, "dm4.workspaces.workspace_facet",
328                new FacetValue("dm4.workspaces.workspace").putRef(workspaceId));
329            // Note: we are refering to an existing workspace. So we must put a topic *reference* (using putRef()).
330            //
331            // 2) store assignment property
332            object.setProperty(PROP_WORKSPACE_ID, workspaceId, false);      // addToIndex=false
333        } catch (Exception e) {
334            throw new RuntimeException("Assigning " + info(object) + " to workspace " + workspaceId + " failed (" +
335                object + ")", e);
336        }
337    }
338
339    // --- Helper ---
340
341    private boolean isDeepaMehtaStandardType(Type type) {
342        return type.getUri().startsWith("dm4.");
343    }
344
345    private boolean isWorkspaceDescription(Topic topic) {
346        return topic.getTypeUri().equals("dm4.workspaces.description");
347    }
348
349    private boolean isWorkspaceAssignment(Association assoc) {
350        if (assoc.getTypeUri().equals("dm4.core.aggregation")) {
351            Topic topic = assoc.getTopic("dm4.core.child");
352            if (topic != null && topic.getTypeUri().equals("dm4.workspaces.workspace")) {
353                return true;
354            }
355        }
356        return false;
357    }
358
359    // ---
360
361    /**
362     * Returns the DeepaMehta workspace or throws an exception if it doesn't exist.
363     */
364    private Topic getDeepaMehtaWorkspace() {
365        return getWorkspace(DEEPAMEHTA_WORKSPACE_URI);
366    }
367
368    private void applyWorkspaceFilter(Iterator<? extends Topic> topics, long workspaceId) {
369        while (topics.hasNext()) {
370            Topic topic = topics.next();
371            if (!isAssignedToWorkspace(topic.getId(), workspaceId)) {
372                topics.remove();
373            }
374        }
375    }
376
377    /**
378     * Checks if the topic with the specified ID exists and is a Workspace. If not, an exception is thrown.
379     */
380    private void checkArgument(long topicId) {
381        String typeUri = dms.getTopic(topicId).getTypeUri();
382        if (!typeUri.equals("dm4.workspaces.workspace")) {
383            throw new IllegalArgumentException("Topic " + topicId + " is not a workspace (but of type \"" + typeUri +
384                "\")");
385        }
386    }
387
388    /**
389     * Returns true if standard workspace assignment is currently suppressed for the current thread.
390     */
391    private boolean workspaceAssignmentIsSuppressed(DeepaMehtaObject object) {
392        boolean abort = dms.getAccessControl().workspaceAssignmentIsSuppressed();
393        if (abort) {
394            logger.info("Standard workspace assignment for " + info(object) + " SUPPRESSED");
395        }
396        return abort;
397    }
398
399    // ---
400
401    // ### FIXME: copied from Access Control
402    // ### TODO: add shortInfo() to DeepaMehtaObject interface
403    private String info(DeepaMehtaObject object) {
404        if (object instanceof TopicType) {
405            return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
406        } else if (object instanceof AssociationType) {
407            return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
408        } else if (object instanceof Topic) {
409            return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() +
410                "\")";
411        } else if (object instanceof Association) {
412            return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")";
413        } else {
414            throw new RuntimeException("Unexpected object: " + object);
415        }
416    }
417}