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}