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-2015 ForgeRock AS.
015 */
016package org.forgerock.opendj.rest2ldap;
017
018import static org.forgerock.json.resource.PatchOperation.operation;
019import static org.forgerock.opendj.ldap.Filter.alwaysFalse;
020import static org.forgerock.opendj.rest2ldap.Rest2LDAP.asResourceException;
021import static org.forgerock.opendj.rest2ldap.Utils.i18n;
022import static org.forgerock.opendj.rest2ldap.Utils.toLowerCase;
023
024import java.util.AbstractMap.SimpleImmutableEntry;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Set;
031
032import org.forgerock.json.JsonPointer;
033import org.forgerock.json.JsonValue;
034import org.forgerock.json.resource.BadRequestException;
035import org.forgerock.json.resource.PatchOperation;
036import org.forgerock.json.resource.ResourceException;
037import org.forgerock.opendj.ldap.Attribute;
038import org.forgerock.opendj.ldap.Entry;
039import org.forgerock.opendj.ldap.Filter;
040import org.forgerock.opendj.ldap.Modification;
041import org.forgerock.util.Function;
042import org.forgerock.util.promise.Promise;
043import org.forgerock.util.promise.Promises;
044
045/** An attribute mapper which maps JSON objects to LDAP attributes. */
046public final class ObjectAttributeMapper extends AttributeMapper {
047
048    private static final class Mapping {
049        private final AttributeMapper mapper;
050        private final String name;
051
052        private Mapping(final String name, final AttributeMapper mapper) {
053            this.name = name;
054            this.mapper = mapper;
055        }
056
057        @Override
058        public String toString() {
059            return name + " -> " + mapper;
060        }
061    }
062
063    private final Map<String, Mapping> mappings = new LinkedHashMap<>();
064
065    ObjectAttributeMapper() {
066        // Nothing to do.
067    }
068
069    /**
070     * Creates a mapping for an attribute contained in the JSON object.
071     *
072     * @param name
073     *            The name of the JSON attribute to be mapped.
074     * @param mapper
075     *            The attribute mapper responsible for mapping the JSON
076     *            attribute to LDAP attribute(s).
077     * @return A reference to this attribute mapper.
078     */
079    public ObjectAttributeMapper attribute(final String name, final AttributeMapper mapper) {
080        mappings.put(toLowerCase(name), new Mapping(name, mapper));
081        return this;
082    }
083
084    @Override
085    public String toString() {
086        return "object(" + mappings.values() + ")";
087    }
088
089    @Override
090    Promise<List<Attribute>, ResourceException> create(
091            final RequestState requestState, final JsonPointer path, final JsonValue v) {
092        try {
093            /*
094             * First check that the JSON value is an object and that the fields
095             * it contains are known by this mapper.
096             */
097            final Map<String, Mapping> missingMappings = checkMapping(path, v);
098
099            // Accumulate the results of the subordinate mappings.
100            final List<Promise<List<Attribute>, ResourceException>> promises = new ArrayList<>();
101
102            // Invoke mappings for which there are values provided.
103            if (v != null && !v.isNull()) {
104                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
105                    final Mapping mapping = getMapping(me.getKey());
106                    final JsonValue subValue = new JsonValue(me.getValue());
107                    promises.add(mapping.mapper.create(requestState, path.child(me.getKey()), subValue));
108                }
109            }
110
111            // Invoke mappings for which there were no values provided.
112            for (final Mapping mapping : missingMappings.values()) {
113                promises.add(mapping.mapper.create(requestState, path.child(mapping.name), null));
114            }
115
116            return Promises.when(promises)
117                           .then(this.<Attribute> accumulateResults());
118        } catch (final Exception e) {
119            return Promises.newExceptionPromise(asResourceException(e));
120        }
121    }
122
123    @Override
124    void getLDAPAttributes(final RequestState requestState, final JsonPointer path, final JsonPointer subPath,
125            final Set<String> ldapAttributes) {
126        if (subPath.isEmpty()) {
127            // Request all subordinate mappings.
128            for (final Mapping mapping : mappings.values()) {
129                mapping.mapper.getLDAPAttributes(requestState, path.child(mapping.name), subPath, ldapAttributes);
130            }
131        } else {
132            // Request single subordinate mapping.
133            final Mapping mapping = getMapping(subPath);
134            if (mapping != null) {
135                mapping.mapper.getLDAPAttributes(
136                        requestState, path.child(subPath.get(0)), subPath.relativePointer(), ldapAttributes);
137            }
138        }
139    }
140
141    @Override
142    Promise<Filter, ResourceException> getLDAPFilter(final RequestState requestState, final JsonPointer path,
143            final JsonPointer subPath, final FilterType type, final String operator, final Object valueAssertion) {
144        final Mapping mapping = getMapping(subPath);
145        if (mapping != null) {
146            return mapping.mapper.getLDAPFilter(requestState, path.child(subPath.get(0)),
147                    subPath.relativePointer(), type, operator, valueAssertion);
148        } else {
149            /*
150             * Either the filter targeted the entire object (i.e. it was "/"),
151             * or it targeted an unrecognized attribute within the object.
152             * Either way, the filter will never match.
153             */
154            return Promises.newResultPromise(alwaysFalse());
155        }
156    }
157
158    @Override
159    Promise<List<Modification>, ResourceException> patch(
160            final RequestState requestState, final JsonPointer path, final PatchOperation operation) {
161        try {
162            final JsonPointer field = operation.getField();
163            final JsonValue v = operation.getValue();
164
165            if (field.isEmpty()) {
166                /*
167                 * The patch operation applies to this object. We'll handle this
168                 * by allowing the JSON value to be a partial object and
169                 * add/remove/replace only the provided values.
170                 */
171                checkMapping(path, v);
172
173                // Accumulate the results of the subordinate mappings.
174                final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
175
176                // Invoke mappings for which there are values provided.
177                if (!v.isNull()) {
178                    for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
179                        final Mapping mapping = getMapping(me.getKey());
180                        final JsonValue subValue = new JsonValue(me.getValue());
181                        final PatchOperation subOperation =
182                                operation(operation.getOperation(), field /* empty */, subValue);
183                        promises.add(mapping.mapper.patch(requestState, path.child(me.getKey()), subOperation));
184                    }
185                }
186
187                return Promises.when(promises)
188                               .then(this.<Modification> accumulateResults());
189            } else {
190                /*
191                 * The patch operation targets a subordinate field. Create a new
192                 * patch operation targeting the field and forward it to the
193                 * appropriate mapper.
194                 */
195                final String fieldName = field.get(0);
196                final Mapping mapping = getMapping(fieldName);
197                if (mapping == null) {
198                    throw new BadRequestException(i18n(
199                            "The request cannot be processed because it included "
200                                    + "an unrecognized field '%s'", path.child(fieldName)));
201                }
202                final PatchOperation subOperation =
203                        operation(operation.getOperation(), field.relativePointer(), v);
204                return mapping.mapper.patch(requestState, path.child(fieldName), subOperation);
205            }
206        } catch (final Exception ex) {
207            return Promises.newExceptionPromise(asResourceException(ex));
208        }
209    }
210
211    @Override
212    Promise<JsonValue, ResourceException> read(final RequestState requestState, final JsonPointer path, final Entry e) {
213        /*
214         * Use an accumulator which will aggregate the results from the
215         * subordinate mappers into a single list. On completion, the
216         * accumulator combines the results into a single JSON map object.
217         */
218        final List<Promise<Map.Entry<String, JsonValue>, ResourceException>> promises =
219                new ArrayList<>(mappings.size());
220
221        for (final Mapping mapping : mappings.values()) {
222            promises.add(mapping.mapper.read(requestState, path.child(mapping.name), e)
223                    .then(new Function<JsonValue, Map.Entry<String, JsonValue>, ResourceException>() {
224                        @Override
225                        public Map.Entry<String, JsonValue> apply(final JsonValue value) {
226                            return value != null ? new SimpleImmutableEntry<String, JsonValue>(mapping.name, value)
227                                                 : null;
228                        }
229                    }));
230        }
231
232        return Promises.when(promises)
233                .then(new Function<List<Map.Entry<String, JsonValue>>, JsonValue, ResourceException>() {
234                    @Override
235                    public JsonValue apply(final List<Map.Entry<String, JsonValue>> value) {
236                        if (value.isEmpty()) {
237                            /*
238                             * No subordinate attributes, so omit the entire
239                             * JSON object from the resource.
240                             */
241                            return null;
242                        } else {
243                            // Combine the sub-attributes into a single JSON object.
244                            final Map<String, Object> result = new LinkedHashMap<>(value.size());
245                            for (final Map.Entry<String, JsonValue> e : value) {
246                                if (e != null) {
247                                    result.put(e.getKey(), e.getValue().getObject());
248                                }
249                            }
250                            return new JsonValue(result);
251                        }
252                    }
253                });
254    }
255
256    @Override
257    Promise<List<Modification>, ResourceException> update(
258            final RequestState requestState, final JsonPointer path, final Entry e, final JsonValue v) {
259        try {
260            // First check that the JSON value is an object and that the fields
261            // it contains are known by this mapper.
262            final Map<String, Mapping> missingMappings = checkMapping(path, v);
263
264            // Accumulate the results of the subordinate mappings.
265            final List<Promise<List<Modification>, ResourceException>> promises = new ArrayList<>();
266
267            // Invoke mappings for which there are values provided.
268            if (v != null && !v.isNull()) {
269                for (final Map.Entry<String, Object> me : v.asMap().entrySet()) {
270                    final Mapping mapping = getMapping(me.getKey());
271                    final JsonValue subValue = new JsonValue(me.getValue());
272                    promises.add(mapping.mapper.update(requestState, path.child(me.getKey()), e, subValue));
273                }
274            }
275
276            // Invoke mappings for which there were no values provided.
277            for (final Mapping mapping : missingMappings.values()) {
278                promises.add(mapping.mapper.update(requestState, path.child(mapping.name), e, null));
279            }
280
281            return Promises.when(promises)
282                           .then(this.<Modification> accumulateResults());
283        } catch (final Exception ex) {
284            return Promises.newExceptionPromise(asResourceException(ex));
285        }
286    }
287
288    private <T> Function<List<List<T>>, List<T>, ResourceException> accumulateResults() {
289        return new Function<List<List<T>>, List<T>, ResourceException>() {
290            @Override
291            public List<T> apply(final List<List<T>> value) {
292                switch (value.size()) {
293                case 0:
294                    return Collections.emptyList();
295                case 1:
296                    return value.get(0);
297                default:
298                    final List<T> attributes = new ArrayList<>(value.size());
299                    for (final List<T> a : value) {
300                        attributes.addAll(a);
301                    }
302                    return attributes;
303                }
304            }
305        };
306    }
307
308    /** Fail immediately if the JSON value has the wrong type or contains unknown attributes. */
309    private Map<String, Mapping> checkMapping(final JsonPointer path, final JsonValue v)
310            throws ResourceException {
311        final Map<String, Mapping> missingMappings = new LinkedHashMap<>(mappings);
312        if (v != null && !v.isNull()) {
313            if (v.isMap()) {
314                for (final String attribute : v.asMap().keySet()) {
315                    if (missingMappings.remove(toLowerCase(attribute)) == null) {
316                        throw new BadRequestException(i18n(
317                                "The request cannot be processed because it included "
318                                        + "an unrecognized field '%s'", path.child(attribute)));
319                    }
320                }
321            } else {
322                throw new BadRequestException(i18n(
323                        "The request cannot be processed because it included "
324                                + "the field '%s' whose value is the wrong type: "
325                                + "an object is expected", path));
326            }
327        }
328        return missingMappings;
329    }
330
331    private Mapping getMapping(final JsonPointer jsonAttribute) {
332        return jsonAttribute.isEmpty() ? null : getMapping(jsonAttribute.get(0));
333    }
334
335    private Mapping getMapping(final String jsonAttribute) {
336        return mappings.get(toLowerCase(jsonAttribute));
337    }
338
339}