001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2012-2016 ForgeRock AS.
015 */
016package org.forgerock.opendj.rest2ldap;
017
018import static org.forgerock.opendj.rest2ldap.Rest2Ldap.simple;
019import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
020import static org.forgerock.json.resource.PatchOperation.operation;
021import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
022import static org.forgerock.opendj.rest2ldap.Rest2Ldap.asResourceException;
023import static org.forgerock.opendj.rest2ldap.Utils.newBadRequestException;
024import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
025import static org.forgerock.util.Utils.joinAsString;
026import static org.forgerock.util.promise.Promises.newResultPromise;
027
028import java.util.ArrayList;
029import java.util.Arrays;
030import java.util.Collection;
031import java.util.Collections;
032import java.util.LinkedHashMap;
033import java.util.List;
034import java.util.Map;
035import java.util.Set;
036import java.util.TreeSet;
037
038import org.forgerock.json.JsonPointer;
039import org.forgerock.json.JsonValue;
040import org.forgerock.json.resource.PatchOperation;
041import org.forgerock.json.resource.ResourceException;
042import org.forgerock.opendj.ldap.Attribute;
043import org.forgerock.opendj.ldap.Connection;
044import org.forgerock.opendj.ldap.Entry;
045import org.forgerock.opendj.ldap.Filter;
046import org.forgerock.opendj.ldap.Modification;
047import org.forgerock.util.Function;
048import org.forgerock.util.Pair;
049import org.forgerock.util.promise.Promise;
050import org.forgerock.util.promise.Promises;
051
052/** An property mapper which maps JSON objects to LDAP attributes. */
053public final class ObjectPropertyMapper extends PropertyMapper {
054    private static final class Mapping {
055        private final PropertyMapper mapper;
056        private final String name;
057
058        private Mapping(final String name, final PropertyMapper mapper) {
059            this.name = name;
060            this.mapper = mapper;
061        }
062
063        @Override
064        public String toString() {
065            return name + " -> " + mapper;
066        }
067    }
068
069    private final Map<String, Mapping> mappings = new LinkedHashMap<>();
070
071    private boolean includeAllUserAttributesByDefault = false;
072    private final Set<String> excludedDefaultUserAttributes = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
073
074    ObjectPropertyMapper() {
075        // Nothing to do.
076    }
077
078    /**
079     * Creates an explicit mapping for a property contained in the JSON object. When user attributes are
080     * {@link #includeAllUserAttributesByDefault included} by default, be careful to {@link
081     * #excludedDefaultUserAttributes exclude} any attributes which have explicit mappings defined using this method,
082     * otherwise they will be duplicated in the JSON representation.
083     *
084     * @param name
085     *            The name of the JSON property to be mapped.
086     * @param mapper
087     *            The property mapper responsible for mapping the JSON attribute to LDAP attribute(s).
088     * @return A reference to this property mapper.
089     */
090    public ObjectPropertyMapper property(final String name, final PropertyMapper mapper) {
091        mappings.put(toLowerCase(name), new Mapping(name, mapper));
092        return this;
093    }
094
095    /**
096     * Specifies whether all LDAP user attributes should be mapped by default using the default schema based mapping
097     * rules. Individual attributes can be excluded using {@link #excludedDefaultUserAttributes} in order to prevent
098     * attributes with explicit mappings being mapped twice.
099     *
100     * @param include {@code true} if all LDAP user attributes be mapped by default.
101     * @return A reference to this property mapper.
102     */
103    public ObjectPropertyMapper includeAllUserAttributesByDefault(final boolean include) {
104        this.includeAllUserAttributesByDefault = include;
105        return this;
106    }
107
108    /**
109     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
110     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
111     * excluded in order to prevent duplication.
112     *
113     * @param attributeNames The list of attributes to be excluded.
114     * @return A reference to this property mapper.
115     */
116    public ObjectPropertyMapper excludedDefaultUserAttributes(final String... attributeNames) {
117        return excludedDefaultUserAttributes(Arrays.asList(attributeNames));
118    }
119
120    /**
121     * Specifies zero or more user attributes which will be excluded from the default user attribute mappings when
122     * enabled using {@link #includeAllUserAttributesByDefault}. Attributes which have explicit mappings should be
123     * excluded in order to prevent duplication.
124     *
125     * @param attributeNames The list of attributes to be excluded.
126     * @return A reference to this property mapper.
127     */
128    public ObjectPropertyMapper excludedDefaultUserAttributes(final Collection<String> attributeNames) {
129        excludedDefaultUserAttributes.addAll(attributeNames);
130        return this;
131    }
132
133    @Override
134    public String toString() {
135        return "object(" + joinAsString(", ", mappings.values()) + ")";
136    }
137
138    @Override
139    Promise<List<Attribute>, ResourceException> create(final Connection connection,
140                                                       final Resource resource, final JsonPointer path,
141                                                       final JsonValue v) {
142        try {
143            // First check that the JSON value is an object and that the fields it contains are known by this mapper.
144            final Map<String, Mapping> missingMappings = validateJsonValue(path, v);
145
146            // Accumulate the results of the subordinate mappings.
147            final List<Promise<List<Attribute>, ResourceException>> promises = new ArrayList<>();
148
149            // Invoke mappings for which there are values provided.
150            if (v != null && !v.isNull()) {
151                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
152                    final Mapping mapping = getMapping(me.getKey());
153                    final JsonValue subValue = new JsonValue(me.getValue());
154                    promises.add(mapping.mapper.create(connection, resource, path.child(me.getKey()),
155                                                       subValue));
156                }
157            }
158
159            // Invoke mappings for which there were no values provided.
160            for (final Mapping mapping : missingMappings.values()) {
161                promises.add(mapping.mapper.create(connection, resource, path.child(mapping.name), null));
162            }
163
164            return Promises.when(promises)
165                           .then(this.<Attribute> accumulateResults());
166        } catch (final Exception e) {
167            return asResourceException(e).asPromise();
168        }
169    }
170
171    @Override
172    void getLdapAttributes(final JsonPointer path, final JsonPointer subPath, final Set<String> ldapAttributes) {
173        if (subPath.isEmpty()) {
174            // Request all subordinate mappings.
175            if (includeAllUserAttributesByDefault) {
176                ldapAttributes.add("*");
177                // Continue because there may be explicit mappings for operational attributes.
178            }
179            for (final Mapping mapping : mappings.values()) {
180                mapping.mapper.getLdapAttributes(path.child(mapping.name), subPath, ldapAttributes);
181            }
182        } else {
183            // Request single subordinate mapping.
184            final Mapping mapping = getMappingOrNull(subPath);
185            if (mapping != null) {
186                mapping.mapper.getLdapAttributes(path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes);
187            }
188        }
189    }
190
191    @Override
192    Promise<Filter, ResourceException> getLdapFilter(final Connection connection, final Resource resource,
193                                                     final JsonPointer path, final JsonPointer subPath,
194                                                     final FilterType type, final String operator,
195                                                     final Object valueAssertion) {
196        final Mapping mapping = getMappingOrNull(subPath);
197        if (mapping != null) {
198            return mapping.mapper.getLdapFilter(connection,
199                                                resource,
200                                                path.child(subPath.get(0)),
201                                                subPath.relativePointer(),
202                                                type,
203                                                operator,
204                                                valueAssertion);
205        } else {
206            /*
207             * Either the filter targeted the entire object (i.e. it was "/"),
208             * or it targeted an unrecognized attribute within the object.
209             * Either way, the filter will never match.
210             */
211            return newResultPromise(alwaysFalse());
212        }
213    }
214
215    @Override
216    Promise<List<Modification>, ResourceException> patch(final Connection connection, final Resource resource,
217                                                         final JsonPointer path, final PatchOperation operation) {
218        try {
219            final JsonPointer field = operation.getField();
220            final JsonValue v = operation.getValue();
221
222            if (field.isEmpty()) {
223                /*
224                 * The patch operation applies to this object. We'll handle this
225                 * by allowing the JSON value to be a partial object and
226                 * add/remove/replace only the provided values.
227                 */
228                validateJsonValue(path, v);
229
230                // Accumulate the results of the subordinate mappings.
231                final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
232
233                // Invoke mappings for which there are values provided.
234                if (!v.isNull()) {
235                    for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
236                        final Mapping mapping = getMapping(me.getKey());
237                        final JsonValue subValue = new JsonValue(me.getValue());
238                        final PatchOperation subOperation =
239                                operation(operation.getOperation(), field /* empty */, subValue);
240                        promises.add(mapping.mapper.patch(connection, resource, path.child(me.getKey()), subOperation));
241                    }
242                }
243
244                return Promises.when(promises)
245                               .then(this.<Modification> accumulateResults());
246            } else {
247                /*
248                 * The patch operation targets a subordinate field. Create a new
249                 * patch operation targeting the field and forward it to the
250                 * appropriate mapper.
251                 */
252                final String fieldName = field.get(0);
253                final Mapping mapping = getMappingOrNull(fieldName);
254                if (mapping == null) {
255                    throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(fieldName)));
256                }
257                final PatchOperation subOperation =
258                        operation(operation.getOperation(), field.relativePointer(), v);
259                return mapping.mapper.patch(connection, resource, path.child(fieldName), subOperation);
260            }
261        } catch (final Exception e) {
262            return asResourceException(e).asPromise();
263        }
264    }
265
266    @Override
267    Promise<JsonValue, ResourceException> read(final Connection connection, final Resource resource,
268                                               final JsonPointer path, final Entry e) {
269        /*
270         * Use an accumulator which will aggregate the results from the
271         * subordinate mappers into a single list. On completion, the
272         * accumulator combines the results into a single JSON map object.
273         */
274        final List<Promise<Pair<String, JsonValue>, ResourceException>> promises =
275                new ArrayList<>(mappings.size());
276
277        for (final Mapping mapping : mappings.values()) {
278            promises.add(mapping.mapper.read(connection, resource, path.child(mapping.name), e)
279                                       .then(toProperty(mapping.name)));
280        }
281
282        if (includeAllUserAttributesByDefault) {
283            // Map all user attributes using a default simple mapping. It would be nice if we could automatically
284            // detect which attributes have been mapped already using explicit mappings, but it would require us to
285            // track which attributes have been accessed in the entry. Instead, we'll rely on the user to exclude
286            // attributes which have explicit mappings.
287            for (final Attribute attribute : e.getAllAttributes()) {
288                // Don't include operational attributes. They must have explicit mappings.
289                if (attribute.getAttributeDescription().getAttributeType().isOperational()) {
290                    continue;
291                }
292                // Filter out excluded attributes.
293                final String attributeName = attribute.getAttributeDescriptionAsString();
294                if (!excludedDefaultUserAttributes.isEmpty() && excludedDefaultUserAttributes.contains(attributeName)) {
295                    continue;
296                }
297                // This attribute needs to be mapped.
298                final SimplePropertyMapper mapper = simple(attribute.getAttributeDescription());
299                promises.add(mapper.read(connection, resource, path.child(attributeName), e)
300                                   .then(toProperty(attributeName)));
301            }
302        }
303
304        return Promises.when(promises)
305                       .then(new Function<List<Pair<String, JsonValue>>, JsonValue, ResourceException>() {
306                           @Override
307                           public JsonValue apply(final List<Pair<String, JsonValue>> value) {
308                               if (value.isEmpty()) {
309                                   // No subordinate attributes, so omit the entire JSON object from the resource.
310                                   return null;
311                               } else {
312                                   // Combine the sub-attributes into a single JSON object.
313                                   final Map<String, Object> result = new LinkedHashMap<>(value.size());
314                                   for (final Pair<String, JsonValue> e : value) {
315                                       if (e != null) {
316                                           result.put(e.getFirst(), e.getSecond().getObject());
317                                       }
318                                   }
319                                   return new JsonValue(result);
320                               }
321                           }
322                       });
323    }
324
325    private Function<JsonValue, Pair<String, JsonValue>, ResourceException> toProperty(final String name) {
326        return new Function<JsonValue, Pair<String, JsonValue>, ResourceException>() {
327            @Override
328            public Pair<String, JsonValue> apply(final JsonValue value) {
329                return value != null ? Pair.of(name, value) : null;
330            }
331        };
332    }
333
334    @Override
335    Promise<List<Modification>, ResourceException> update(final Connection connection, final Resource resource,
336                                                          final JsonPointer path, final Entry e, final JsonValue v) {
337        try {
338            // First check that the JSON value is an object and that the fields it contains are known by this mapper.
339            final Map<String, Mapping> missingMappings = validateJsonValue(path, v);
340
341            // Accumulate the results of the subordinate mappings.
342            final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
343
344            // Invoke mappings for which there are values provided.
345            if (v != null && !v.isNull()) {
346                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
347                    final Mapping mapping = getMapping(me.getKey());
348                    final JsonValue subValue = new JsonValue(me.getValue());
349                    promises.add(mapping.mapper.update(connection, resource, path.child(me.getKey()), e, subValue));
350                }
351            }
352
353            // Invoke mappings for which there were no values provided.
354            for (final Mapping mapping : missingMappings.values()) {
355                promises.add(mapping.mapper.update(connection, resource, path.child(mapping.name), e, null));
356            }
357
358            return Promises.when(promises)
359                           .then(this.<Modification> accumulateResults());
360        } catch (final Exception ex) {
361            return asResourceException(ex).asPromise();
362        }
363    }
364
365    private <T> Function<List<List<T>>, List<T>, ResourceException> accumulateResults() {
366        return new Function<List<List<T>>, List<T>, ResourceException>() {
367            @Override
368            public List<T> apply(final List<List<T>> value) {
369                switch (value.size()) {
370                case 0:
371                    return Collections.emptyList();
372                case 1:
373                    return value.get(0);
374                default:
375                    final List<T> attributes = new ArrayList<>(value.size());
376                    for (final List<T> a : value) {
377                        attributes.addAll(a);
378                    }
379                    return attributes;
380                }
381            }
382        };
383    }
384
385    /** Fail immediately if the JSON value has the wrong type or contains unknown attributes. */
386    private Map<String, Mapping> validateJsonValue(final JsonPointer path, final JsonValue v) throws ResourceException {
387        final Map<String, Mapping> missingMappings = new LinkedHashMap<>(mappings);
388        if (v != null && !v.isNull()) {
389            if (v.isMap()) {
390                for (final String attribute : v.asMap().keySet()) {
391                    if (missingMappings.remove(toLowerCase(attribute)) == null
392                            && !isIncludedDefaultUserAttribute(attribute)) {
393                        throw newBadRequestException(ERR_UNRECOGNIZED_FIELD.get(path.child(attribute)));
394                    }
395                }
396            } else {
397                throw newBadRequestException(ERR_FIELD_WRONG_TYPE.get(path));
398            }
399        }
400        return missingMappings;
401    }
402
403    private Mapping getMappingOrNull(final JsonPointer jsonAttribute) {
404        return jsonAttribute.isEmpty() ? null : getMappingOrNull(jsonAttribute.get(0));
405    }
406
407    private Mapping getMappingOrNull(final String jsonAttribute) {
408        final Mapping mapping = mappings.get(toLowerCase(jsonAttribute));
409        if (mapping != null) {
410            return mapping;
411        }
412        if (isIncludedDefaultUserAttribute(jsonAttribute)) {
413            return new Mapping(jsonAttribute, simple(jsonAttribute));
414        }
415        return null;
416    }
417
418    private Mapping getMapping(final String jsonAttribute) {
419        final Mapping mappingOrNull = getMappingOrNull(jsonAttribute);
420        if (mappingOrNull != null) {
421            return mappingOrNull;
422        }
423        throw new IllegalStateException("Unexpected null mapping for jsonAttribute: " + jsonAttribute);
424    }
425
426    private boolean isIncludedDefaultUserAttribute(final String attributeName) {
427        return includeAllUserAttributesByDefault
428                && (excludedDefaultUserAttributes.isEmpty() || !excludedDefaultUserAttributes.contains(attributeName));
429    }
430}