/*  * The contents of this file are subject to the terms of the Common Development and  * Distribution License (the License). You may not use this file except in compliance with the  * License.  *  * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the  * specific language governing permission and limitations under the License.  *  * When distributing Covered Software, include this CDDL Header Notice in each file and include  * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL  * Header, with the fields enclosed by brackets [] replaced by your own identifying  * information: "Portions copyright [year] [name of copyright owner]".  *  * Copyright 2015 ForgeRock AS.  */ package org.forgerock.openidm.managed; import static org.forgerock.json.JsonValue.*; import static org.forgerock.json.resource.ResourceResponse.*; import static org.forgerock.json.resource.Router.uriTemplate; import static org.forgerock.openidm.util.ResourceUtil.notSupportedOnCollection; import static org.forgerock.util.promise.Promises.*; import static org.forgerock.util.query.QueryFilter.*; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import org.forgerock.http.routing.RoutingMode; import org.forgerock.json.JsonPointer; import org.forgerock.json.JsonValue; import org.forgerock.json.resource.ActionRequest; import org.forgerock.json.resource.ActionResponse; import org.forgerock.json.resource.BadRequestException; import org.forgerock.json.resource.CollectionResourceProvider; import org.forgerock.json.resource.ConnectionFactory; import org.forgerock.json.resource.CreateRequest; import org.forgerock.json.resource.DeleteRequest; import org.forgerock.json.resource.InternalServerErrorException; import org.forgerock.json.resource.QueryRequest; import org.forgerock.json.resource.QueryResourceHandler; import org.forgerock.json.resource.QueryResponse; import org.forgerock.json.resource.RequestHandler; import org.forgerock.json.resource.Requests; import org.forgerock.json.resource.ResourceException; import org.forgerock.json.resource.ResourcePath; import org.forgerock.json.resource.ResourceResponse; import org.forgerock.json.resource.Resources; import org.forgerock.json.resource.Router; import org.forgerock.json.resource.SortKey; import org.forgerock.json.resource.UpdateRequest; import org.forgerock.json.resource.http.HttpUtils; import org.forgerock.openidm.audit.util.ActivityLogger; import org.forgerock.openidm.audit.util.Status; import org.forgerock.openidm.core.ServerConstants; import org.forgerock.openidm.smartevent.EventEntry; import org.forgerock.openidm.smartevent.Name; import org.forgerock.openidm.smartevent.Publisher; import org.forgerock.openidm.util.RelationshipUtil; import org.forgerock.services.context.Context; import org.forgerock.util.AsyncFunction; import org.forgerock.util.Function; import org.forgerock.util.promise.Promise; import org.forgerock.util.query.QueryFilter; import org.forgerock.util.query.QueryFilterVisitor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A {@link RelationshipProvider} representing a collection (array) of relationships for the given field. */ class CollectionRelationshipProvider extends RelationshipProvider implements CollectionResourceProvider { /** * Setup logging for the {@link CollectionRelationshipProvider}. */ private static final Logger logger = LoggerFactory.getLogger(CollectionRelationshipProvider.class); final static QueryFilterVisitor<QueryFilter<JsonPointer>, Boolean, JsonPointer> VISITOR = new RelationshipQueryFilterVisitor(); private final RequestHandler requestHandler; /** * Create a new relationship set for the given managed resource * @param connectionFactory Connection factory used to access the repository * @param resourcePath Name of the resource we are handling relationships for eg. managed/user * @param schemaField The schema of the field representing this relationship in the parent object. * @param activityLogger The audit activity logger to use * @param managedObjectSyncService Service to send sync events to */ public CollectionRelationshipProvider(final ConnectionFactory connectionFactory, final ResourcePath resourcePath, final SchemaField schemaField, final ActivityLogger activityLogger, final ManagedObjectSyncService managedObjectSyncService) { super(connectionFactory, resourcePath, schemaField, activityLogger, managedObjectSyncService); final Router router = new Router(); router.addRoute(RoutingMode.STARTS_WITH, uriTemplate(String.format("{%s}/%s", PARAM_MANAGED_OBJECT_ID, schemaField.getName())), Resources.newCollection(this)); this.requestHandler = router; } /** {@inheritDoc} */ @Override public RequestHandler asRequestHandler() { return requestHandler; } /** {@inheritDoc} */ @Override public Promise<JsonValue, ResourceException> getRelationshipValueForResource(final Context context, final String resourceId) { EventEntry measure = Publisher.start(Name.get("openidm/internal/relationship/collection/getRelationshipValueForResource"), resourceId, context); try { final QueryRequest queryRequest = Requests.newQueryRequest("") .setAdditionalParameter(PARAM_MANAGED_OBJECT_ID, resourceId) .setQueryId(RELATIONSHIP_QUERY_ID); final List<ResourceResponse> relationships = new ArrayList<>(); queryCollection(new ManagedObjectContext(context), queryRequest, new QueryResourceHandler() { @Override public boolean handleResource(ResourceResponse resourceResponse) { relationships.add(resourceResponse); return true; } }).getOrThrowUninterruptibly(); // call get() so we block until we have all items final JsonValue buf = json(array()); for (ResourceResponse resource : relationships) { buf.add(resource.getContent().getObject()); } return newResultPromise(buf); } catch (ResourceException e) { return e.asPromise(); } finally { measure.end(); } } @Override public Promise<JsonValue, ResourceException> setRelationshipValueForResource(final boolean clearExisting, Context context, String resourceId, JsonValue relationships) { EventEntry measure = Publisher.start(Name.get("openidm/internal/relationship/collection/setRelationshipValueForResource"), resourceId, context); try { relationships.expect(List.class); // Set of relationship IDs for updating (don't delete) final Set<String> relationshipsToKeep = new HashSet<>(); // Set of relationships to perform an update on (have an _id) final List<JsonValue> relationshipsToUpdate = new ArrayList<>(); // Set of relationships to create (no _id field) final List<JsonValue> relationshipsToCreate = new ArrayList<>(); // JsonValue array to contain persisted relations final JsonValue results = json(array()); try { if (relationships.isNotNull() && !relationships.asList().isEmpty()) { // Split relationships in to to-be-updated (_id present) and to-be-created for (JsonValue relationship : relationships) { final JsonValue id = relationship.get(FIELD_ID); if (id != null && id.isNotNull()) { // need update relationshipsToUpdate.add(relationship); relationshipsToKeep.add(id.asString()); } else { // no id. create relationshipsToCreate.add(relationship); } } if (!clearExisting) { // Call get() so we block until they are deleted. clearNotIn(context, resourceId, relationshipsToKeep).getOrThrowUninterruptibly(); } } else { // We didn't get any relations to persist. Clear and return empty array. if (!clearExisting) { clear(context, resourceId); } return newResultPromise(results); } /* * Create or update relationships */ // List of promises returned by update and create to when() on later final List<Promise<ResourceResponse, ResourceException>> promises = new ArrayList<>(); for (JsonValue toUpdate : relationshipsToUpdate) { final UpdateRequest updateRequest = Requests.newUpdateRequest("", toUpdate) .setAdditionalParameter(PARAM_MANAGED_OBJECT_ID, resourceId); promises.add(updateInstance(context, toUpdate.get(FIELD_ID).asString(), updateRequest)); } for (JsonValue toCreate : relationshipsToCreate) { final CreateRequest createRequest = Requests.newCreateRequest("", toCreate) .setAdditionalParameter(PARAM_MANAGED_OBJECT_ID, resourceId); promises.add(createInstance(context, createRequest)); } return when(promises).then(new Function<List<ResourceResponse>, JsonValue, ResourceException>() { @Override public JsonValue apply(List<ResourceResponse> responses) throws ResourceException { final JsonValue value = json(array()); for (final ResourceResponse response : responses) { value.add(response.getContent().getObject()); } return value; } }); } catch (ResourceException e) { return e.asPromise(); } } finally { measure.end(); } } /** * Clear all relationships not present in {@code relationshipsToKeep}. * * @param context The current context. * @param resourceId The resource whose relationships we wish to clear * @param relationshipsToKeep Set of relationship ids that should not be deleted * @return A promised JsonValue array of delete responses */ private Promise<JsonValue, ResourceException> clearNotIn(final Context context, final String resourceId, final Set<String> relationshipsToKeep) { EventEntry measure = Publisher.start(Name.get("openidm/internal/relationship/collection/clearNotIn"), resourceId, context); try { return getRelationshipValueForResource(context, resourceId).thenAsync(new AsyncFunction<JsonValue, JsonValue, ResourceException>() { @Override public Promise<JsonValue, ResourceException> apply(JsonValue existingRelationships) throws ResourceException { final List<Promise<ResourceResponse, ResourceException>> promises = new ArrayList<>(); for (JsonValue relationship : existingRelationships) { final String id = relationship.get(FIELD_ID).asString(); // Delete if we're not told to keep this id if (!relationshipsToKeep.contains(id)) { final DeleteRequest deleteRequest = Requests.newDeleteRequest("", id) .setAdditionalParameter(PARAM_MANAGED_OBJECT_ID, resourceId); promises.add(deleteInstance(context, id, deleteRequest)); } } return when(promises).then(new Function<List<ResourceResponse>, JsonValue, ResourceException>() { @Override public JsonValue apply(List<ResourceResponse> resourceResponses) throws ResourceException { final JsonValue result = json(array()); for (ResourceResponse resourceResponse : resourceResponses) { result.add(resourceResponse.getContent()); } return result; } }); } }); } finally { measure.end(); } } /** {@inheritDoc} */ @Override public Promise<JsonValue, ResourceException> clear(final Context context, final String resourceId) { EventEntry measure = Publisher.start(Name.get("openidm/internal/relationship/collection/clear"), resourceId, null); try { /* * FIXME - Performance here is terrible. We read every relationship just to get the id to delete. * Need a deleteByQueryFilter() to remove all relationships for a given firstId */ return getRelationshipValueForResource(context, resourceId).thenAsync(new AsyncFunction<JsonValue, JsonValue, ResourceException>() { @Override public Promise<JsonValue, ResourceException> apply(JsonValue existing) throws ResourceException { final List<Promise<ResourceResponse, ResourceException>> deleted = new ArrayList<>(); for (JsonValue relationship : existing) { final DeleteRequest deleteRequest = Requests.newDeleteRequest("") .setAdditionalParameter(PARAM_MANAGED_OBJECT_ID, resourceId); deleted.add(deleteInstance(context, relationship.get(FIELD_ID).asString(), deleteRequest)); } return when(deleted).then(new Function<List<ResourceResponse>, JsonValue, ResourceException>() { @Override public JsonValue apply(List<ResourceResponse> resourceResponses) throws ResourceException { final JsonValue deleted = json(array()); for (ResourceResponse resourceResponse : resourceResponses) { deleted.add(resourceResponse.getContent()); } return deleted; } }); } }); } finally { measure.end(); } } /** {@inheritDoc} */ @Override public Promise<ActionResponse, ResourceException> actionCollection(Context context, ActionRequest request) { return notSupportedOnCollection(request).asPromise(); } /** {@inheritDoc} */ @Override public Promise<QueryResponse, ResourceException> queryCollection(final Context context, final QueryRequest request, final QueryResourceHandler handler) { try { if (request.getQueryExpression() != null) { return new BadRequestException(HttpUtils.PARAM_QUERY_EXPRESSION + " not supported").asPromise(); } /* * Create new request copying all attributes but fields. * This must be done so field filtering can be handled by CREST externally on the transformed response. */ ResourcePath resourcePath = firstResourcePath(context, request); final QueryRequest queryRequest = Requests.newQueryRequest(REPO_RESOURCE_PATH); final boolean queryAllIds = ServerConstants.QUERY_ALL_IDS.equals(request.getQueryId()); if (request.getQueryId() != null) { if (ServerConstants.QUERY_ALL_IDS.equals(request.getQueryId()) || "query-all".equals(request.getQueryId())) { // Do nothing, the queryFilter generated below will return all } else if (RELATIONSHIP_QUERY_ID.equals(request.getQueryId())) { // Optimized query queryRequest.setQueryId(RELATIONSHIP_QUERY_ID) .setAdditionalParameter(QUERY_FIELD_RESOURCE_PATH, resourcePath.toString()) .setAdditionalParameter(QUERY_FIELD_FIELD_NAME, schemaField.getName()); } else { return new BadRequestException("Invalid " + HttpUtils.PARAM_QUERY_ID + ": only query-all and query-all-ids supported").asPromise(); } } queryRequest.setPageSize(request.getPageSize()); queryRequest.setPagedResultsOffset(request.getPagedResultsOffset()); queryRequest.setPagedResultsCookie(request.getPagedResultsCookie()); queryRequest.setTotalPagedResultsPolicy(request.getTotalPagedResultsPolicy()); queryRequest.addSortKey(request.getSortKeys().toArray(new SortKey[request.getSortKeys().size()])); for (String key : request.getAdditionalParameters().keySet()) { queryRequest.setAdditionalParameter(key, request.getAdditionalParameter(key)); } // Check if using a queryId, if not build up the queryFilter if (queryRequest.getQueryId() == null) { QueryFilter<JsonPointer> filter; if (schemaField.isReverseRelationship()) { // Reverse relationship requires a queryFilter that matches both cases where the managed object's // resource path and schema field match the firstId/firstPropertyName or the // secondId/secondPropertyName. QueryFilter<JsonPointer> firstFilter = and( equalTo(new JsonPointer(REPO_FIELD_FIRST_ID), resourcePath), equalTo(new JsonPointer(REPO_FIELD_FIRST_PROPERTY_NAME), schemaField.getName())); QueryFilter<JsonPointer> secondFilter = and( equalTo(new JsonPointer(REPO_FIELD_SECOND_ID), resourcePath), equalTo(new JsonPointer(REPO_FIELD_SECOND_PROPERTY_NAME), schemaField.getName())); if (request.getQueryFilter() != null) { // AND the supplied queryFilter to both of the above generated filters and then OR them filter = or(and(firstFilter, asRelationshipQueryFilter(false, request.getQueryFilter())), and(secondFilter, asRelationshipQueryFilter(true, request.getQueryFilter()))); } else { // OR the filters together filter = or(firstFilter, secondFilter); } } else { // A direct relationship requires a queryFilter that matches only the case where the managed // object's resource path and schema field match the firstId/firstPropertyName. filter = and(equalTo(new JsonPointer(REPO_FIELD_FIRST_ID), resourcePath), equalTo(new JsonPointer(REPO_FIELD_FIRST_PROPERTY_NAME), schemaField.getName())); if (request.getQueryFilter() != null) { filter = and(filter, asRelationshipQueryFilter(schemaField.isReverseRelationship(), request.getQueryFilter())); } } queryRequest.setQueryFilter(filter); } // Issue the query and handle the response final Promise<QueryResponse, ResourceException> response = getConnection().queryAsync(context, queryRequest, new QueryResourceHandler() { @Override public boolean handleResource(ResourceResponse resource) { ResourceResponse filteredResourceResponse = formatResponseNoException(context, request).apply(resource); if (queryAllIds) { // Special case, return just the ids, no expansion filteredResourceResponse.addField(FIELD_ID); return handler.handleResource(filteredResourceResponse); } try { filteredResourceResponse = expandFields(context, request, filteredResourceResponse).getOrThrow(); } catch (Exception e) { logger.error("Error expanding resource: " + e.getMessage(), e); } return handler.handleResource(filteredResourceResponse); } }); if (context.containsContext(ManagedObjectContext.class)) { return response; } QueryResponse result = response.getOrThrow(); // Get the value of the managed object final ResourceResponse value = getManagedObject(context); // Do activity logging. activityLogger.log(context, request, "query", getManagedObjectPath(context), null, value.getContent(), Status.SUCCESS); return newResultPromise(result); } catch (ResourceException e) { return e.asPromise(); } catch (Exception e) { return new InternalServerErrorException(e.getMessage(), e).asPromise(); } } static QueryFilter<JsonPointer> asRelationshipQueryFilter(Boolean isReverse, QueryFilter<JsonPointer> filter) { return filter.accept(VISITOR, isReverse); } /** * A {@link QueryFilterVisitor} implementation which modifies the {@link JsonPointer} fields by prepending them * with the appropriate key where the full config object is located. */ private static class RelationshipQueryFilterVisitor implements QueryFilterVisitor<QueryFilter<JsonPointer>, Boolean, JsonPointer> { @Override public QueryFilter<JsonPointer> visitAndFilter(Boolean isReverse, List<QueryFilter<JsonPointer>> subFilters) { return and(visitQueryFilters(isReverse, subFilters)); } @Override public QueryFilter<JsonPointer> visitBooleanLiteralFilter(Boolean isReverse, boolean value) { return value ? QueryFilter.<JsonPointer>alwaysTrue() : QueryFilter.<JsonPointer>alwaysFalse(); } @Override public QueryFilter<JsonPointer> visitContainsFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return contains(getRelationshipPointer(isReverse, field), valueAssertion); } @Override public QueryFilter<JsonPointer> visitEqualsFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return equalTo(getRelationshipPointer(isReverse, field), valueAssertion); } @Override public QueryFilter<JsonPointer> visitExtendedMatchFilter(Boolean isReverse, JsonPointer field, String operator, Object valueAssertion) { return comparisonFilter(getRelationshipPointer(isReverse, field), operator, valueAssertion); } @Override public QueryFilter<JsonPointer> visitGreaterThanFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return greaterThan(getRelationshipPointer(isReverse, field), valueAssertion); } @Override public QueryFilter<JsonPointer> visitGreaterThanOrEqualToFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return greaterThanOrEqualTo(getRelationshipPointer(isReverse, field), valueAssertion); } @Override public QueryFilter<JsonPointer> visitLessThanFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return lessThan(getRelationshipPointer(isReverse, field), valueAssertion); } @Override public QueryFilter<JsonPointer> visitLessThanOrEqualToFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return lessThanOrEqualTo(getRelationshipPointer(isReverse, field), valueAssertion); } @Override public QueryFilter<JsonPointer> visitNotFilter(Boolean isReverse, QueryFilter<JsonPointer> subFilter) { return not(subFilter.accept(new RelationshipQueryFilterVisitor(), null)); } @Override public QueryFilter<JsonPointer> visitOrFilter(Boolean isReverse, List<QueryFilter<JsonPointer>> subFilters) { return or(visitQueryFilters(isReverse, subFilters)); } @Override public QueryFilter<JsonPointer> visitPresentFilter(Boolean isReverse, JsonPointer field) { return present(getRelationshipPointer(isReverse, field)); } @Override public QueryFilter<JsonPointer> visitStartsWithFilter(Boolean isReverse, JsonPointer field, Object valueAssertion) { return startsWith(getRelationshipPointer(isReverse, field), valueAssertion); } /** * Visits each {@link QueryFilter} in a list of filters and returns a list of the * visited filters. * * @param subFilters a list of the filters to visit * @return a list of visited filters */ private List<QueryFilter<JsonPointer>> visitQueryFilters(Boolean isReverse, List<QueryFilter<JsonPointer>> subFilters) { List<QueryFilter<JsonPointer>> visitedFilters = new ArrayList<>(); for (QueryFilter<JsonPointer> filter : subFilters) { visitedFilters.add(asRelationshipQueryFilter(isReverse, filter)); } return visitedFilters; } /** * Converts relationship client object pointers to repo format. * * Converts /_refProperties/_id to /_id * Converts /_refProperties/_rev to /_rev * Converts /_ref to /secondId * Converts /_refProperties/... to /properties/... * * @param isReverse Whether or not this is a reverse relationship * @param field a {@link JsonPointer} representing the field to modify. * @return a {@link JsonPointer} representing the modified field */ private JsonPointer getRelationshipPointer(Boolean isReverse, JsonPointer field) { // /_revProperties/_id to /_id if (FIELD_ID.equals(field)) { return new JsonPointer(FIELD_CONTENT_ID); } // /_refProperties/_rev to /_rev if (FIELD_REV.equals(field)) { return new JsonPointer(FIELD_CONTENT_REVISION); } // /_ref to /firstId or /secondId if (FIELD_REFERENCE.equals(field)) { return new JsonPointer(isReverse ? REPO_FIELD_FIRST_ID : REPO_FIELD_SECOND_ID); } // /_refProperties/... to /properties/... if (FIELD_PROPERTIES.leaf().equals(field.get(0))) { JsonPointer ptr = new JsonPointer(REPO_FIELD_PROPERTIES); for (String s : field.relativePointer(field.size() - 1)) { ptr = ptr.child(s); } return ptr; } // TODO: OPENIDM-4153 don't expose direct repo properties return field; } } /** * Implemented to iterate through the collection calling validateRelationship for each relationship within the * relationshipField. * * @param context context of the original request. * @param oldValue old value of field to validate * @param newValue new value of field to validate * @throws BadRequestException when the relationship isn't valid, ResourceException otherwise. * @see RelationshipValidator#validateRelationship(JsonValue, Context) */ public void validateRelationshipField(Context context, JsonValue oldValue, JsonValue newValue) throws ResourceException { Set<String> oldReferences = new HashSet<>(); if (oldValue.isNotNull()) { for (JsonValue oldItem : oldValue) { oldReferences.add(oldItem.get(RelationshipUtil.REFERENCE_ID).asString()); } } for (JsonValue newItem : newValue) { // If the relationship is found in the existing/old relationships, then we can skip validation. if (!oldReferences.contains(newItem.get(RelationshipUtil.REFERENCE_ID).asString())) { logger.debug("validating new relationship {} for {}: ", newItem, propertyPtr); relationshipValidator.validateRelationship(newItem, context); } } } }