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