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}