001/*- 002 * #%L 003 * HAPI FHIR JPA - Search Parameters 004 * %% 005 * Copyright (C) 2014 - 2024 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.searchparam.registry; 021 022import ca.uhn.fhir.context.ComboSearchParamType; 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.context.RuntimeSearchParam; 025import ca.uhn.fhir.context.phonetic.IPhoneticEncoder; 026import ca.uhn.fhir.i18n.Msg; 027import ca.uhn.fhir.model.api.ExtensionDt; 028import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; 029import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 030import ca.uhn.fhir.util.DatatypeUtil; 031import ca.uhn.fhir.util.ExtensionUtil; 032import ca.uhn.fhir.util.FhirTerser; 033import ca.uhn.fhir.util.HapiExtensions; 034import ca.uhn.fhir.util.PhoneticEncoderUtil; 035import org.apache.commons.lang3.StringUtils; 036import org.hl7.fhir.dstu3.model.Extension; 037import org.hl7.fhir.dstu3.model.SearchParameter; 038import org.hl7.fhir.instance.model.api.IBase; 039import org.hl7.fhir.instance.model.api.IBaseDatatype; 040import org.hl7.fhir.instance.model.api.IBaseExtension; 041import org.hl7.fhir.instance.model.api.IBaseHasExtensions; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.hl7.fhir.instance.model.api.IIdType; 044import org.hl7.fhir.instance.model.api.IPrimitiveType; 045import org.slf4j.Logger; 046import org.slf4j.LoggerFactory; 047import org.springframework.beans.factory.annotation.Autowired; 048import org.springframework.stereotype.Service; 049 050import java.util.ArrayList; 051import java.util.Collection; 052import java.util.Collections; 053import java.util.HashSet; 054import java.util.List; 055import java.util.Set; 056import java.util.stream.Collectors; 057 058import static org.apache.commons.lang3.StringUtils.isBlank; 059import static org.apache.commons.lang3.StringUtils.isNotBlank; 060import static org.apache.commons.lang3.StringUtils.startsWith; 061 062@Service 063public class SearchParameterCanonicalizer { 064 private static final Logger ourLog = LoggerFactory.getLogger(SearchParameterCanonicalizer.class); 065 066 private final FhirContext myFhirContext; 067 private final FhirTerser myTerser; 068 069 @Autowired 070 public SearchParameterCanonicalizer(FhirContext theFhirContext) { 071 myFhirContext = theFhirContext; 072 myTerser = myFhirContext.newTerser(); 073 } 074 075 private static Collection<String> toStrings(Collection<? extends IPrimitiveType<String>> theBase) { 076 HashSet<String> retVal = new HashSet<>(); 077 for (IPrimitiveType<String> next : theBase) { 078 if (isNotBlank(next.getValueAsString())) { 079 retVal.add(next.getValueAsString()); 080 } 081 } 082 return retVal; 083 } 084 085 public RuntimeSearchParam canonicalizeSearchParameter(IBaseResource theSearchParameter) { 086 RuntimeSearchParam retVal; 087 switch (myFhirContext.getVersion().getVersion()) { 088 case DSTU2: 089 retVal = canonicalizeSearchParameterDstu2( 090 (ca.uhn.fhir.model.dstu2.resource.SearchParameter) theSearchParameter); 091 break; 092 case DSTU3: 093 retVal = 094 canonicalizeSearchParameterDstu3((org.hl7.fhir.dstu3.model.SearchParameter) theSearchParameter); 095 break; 096 case R4: 097 case R4B: 098 case R5: 099 retVal = canonicalizeSearchParameterR4Plus(theSearchParameter); 100 break; 101 case DSTU2_HL7ORG: 102 case DSTU2_1: 103 // Non-supported - these won't happen so just fall through 104 default: 105 throw new InternalErrorException( 106 Msg.code(510) + "SearchParameter canonicalization not supported for FHIR version" 107 + myFhirContext.getVersion().getVersion()); 108 } 109 110 if (retVal != null) { 111 extractExtensions(theSearchParameter, retVal); 112 } 113 114 return retVal; 115 } 116 117 private RuntimeSearchParam canonicalizeSearchParameterDstu2( 118 ca.uhn.fhir.model.dstu2.resource.SearchParameter theNextSp) { 119 String name = theNextSp.getCode(); 120 String description = theNextSp.getDescription(); 121 String path = theNextSp.getXpath(); 122 123 Collection<String> baseResource = toStrings(Collections.singletonList(theNextSp.getBaseElement())); 124 List<String> baseCustomResources = extractDstu2CustomResourcesFromExtensions( 125 theNextSp, HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE); 126 127 if (!baseCustomResources.isEmpty()) { 128 baseResource = Collections.singleton(baseCustomResources.get(0)); 129 } 130 131 RestSearchParameterTypeEnum paramType = null; 132 RuntimeSearchParam.RuntimeSearchParamStatusEnum status = null; 133 if (theNextSp.getTypeElement().getValueAsEnum() != null) { 134 switch (theNextSp.getTypeElement().getValueAsEnum()) { 135 case COMPOSITE: 136 paramType = RestSearchParameterTypeEnum.COMPOSITE; 137 break; 138 case DATE_DATETIME: 139 paramType = RestSearchParameterTypeEnum.DATE; 140 break; 141 case NUMBER: 142 paramType = RestSearchParameterTypeEnum.NUMBER; 143 break; 144 case QUANTITY: 145 paramType = RestSearchParameterTypeEnum.QUANTITY; 146 break; 147 case REFERENCE: 148 paramType = RestSearchParameterTypeEnum.REFERENCE; 149 break; 150 case STRING: 151 paramType = RestSearchParameterTypeEnum.STRING; 152 break; 153 case TOKEN: 154 paramType = RestSearchParameterTypeEnum.TOKEN; 155 break; 156 case URI: 157 paramType = RestSearchParameterTypeEnum.URI; 158 break; 159 } 160 } 161 if (theNextSp.getStatus() != null) { 162 switch (theNextSp.getStatusElement().getValueAsEnum()) { 163 case ACTIVE: 164 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.ACTIVE; 165 break; 166 case DRAFT: 167 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.DRAFT; 168 break; 169 case RETIRED: 170 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.RETIRED; 171 break; 172 } 173 } 174 175 Set<String> targetResources = DatatypeUtil.toStringSet(theNextSp.getTarget()); 176 List<String> targetCustomResources = extractDstu2CustomResourcesFromExtensions( 177 theNextSp, HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_TARGET_RESOURCE); 178 179 maybeAddCustomResourcesToResources(targetResources, targetCustomResources); 180 181 if (isBlank(name) || isBlank(path)) { 182 if (paramType != RestSearchParameterTypeEnum.COMPOSITE) { 183 return null; 184 } 185 } 186 187 IIdType id = theNextSp.getIdElement(); 188 String uri = ""; 189 ComboSearchParamType unique = null; 190 191 List<ExtensionDt> uniqueExts = theNextSp.getUndeclaredExtensionsByUrl(HapiExtensions.EXT_SP_UNIQUE); 192 if (uniqueExts.size() > 0) { 193 IPrimitiveType<?> uniqueExtsValuePrimitive = uniqueExts.get(0).getValueAsPrimitive(); 194 if (uniqueExtsValuePrimitive != null) { 195 if ("true".equalsIgnoreCase(uniqueExtsValuePrimitive.getValueAsString())) { 196 unique = ComboSearchParamType.UNIQUE; 197 } else if ("false".equalsIgnoreCase(uniqueExtsValuePrimitive.getValueAsString())) { 198 unique = ComboSearchParamType.NON_UNIQUE; 199 } 200 } 201 } 202 203 List<RuntimeSearchParam.Component> components = Collections.emptyList(); 204 return new RuntimeSearchParam( 205 id, 206 uri, 207 name, 208 description, 209 path, 210 paramType, 211 Collections.emptySet(), 212 targetResources, 213 status, 214 unique, 215 components, 216 baseResource); 217 } 218 219 private RuntimeSearchParam canonicalizeSearchParameterDstu3(org.hl7.fhir.dstu3.model.SearchParameter theNextSp) { 220 String name = theNextSp.getCode(); 221 String description = theNextSp.getDescription(); 222 String path = theNextSp.getExpression(); 223 224 List<String> baseResources = new ArrayList<>(toStrings(theNextSp.getBase())); 225 List<String> baseCustomResources = extractDstu3CustomResourcesFromExtensions( 226 theNextSp, HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE); 227 228 maybeAddCustomResourcesToResources(baseResources, baseCustomResources); 229 230 RestSearchParameterTypeEnum paramType = null; 231 RuntimeSearchParam.RuntimeSearchParamStatusEnum status = null; 232 if (theNextSp.getType() != null) { 233 switch (theNextSp.getType()) { 234 case COMPOSITE: 235 paramType = RestSearchParameterTypeEnum.COMPOSITE; 236 break; 237 case DATE: 238 paramType = RestSearchParameterTypeEnum.DATE; 239 break; 240 case NUMBER: 241 paramType = RestSearchParameterTypeEnum.NUMBER; 242 break; 243 case QUANTITY: 244 paramType = RestSearchParameterTypeEnum.QUANTITY; 245 break; 246 case REFERENCE: 247 paramType = RestSearchParameterTypeEnum.REFERENCE; 248 break; 249 case STRING: 250 paramType = RestSearchParameterTypeEnum.STRING; 251 break; 252 case TOKEN: 253 paramType = RestSearchParameterTypeEnum.TOKEN; 254 break; 255 case URI: 256 paramType = RestSearchParameterTypeEnum.URI; 257 break; 258 case NULL: 259 break; 260 } 261 } 262 if (theNextSp.getStatus() != null) { 263 switch (theNextSp.getStatus()) { 264 case ACTIVE: 265 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.ACTIVE; 266 break; 267 case DRAFT: 268 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.DRAFT; 269 break; 270 case RETIRED: 271 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.RETIRED; 272 break; 273 case UNKNOWN: 274 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.UNKNOWN; 275 break; 276 case NULL: 277 break; 278 } 279 } 280 281 Set<String> targetResources = DatatypeUtil.toStringSet(theNextSp.getTarget()); 282 List<String> targetCustomResources = extractDstu3CustomResourcesFromExtensions( 283 theNextSp, HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_TARGET_RESOURCE); 284 285 maybeAddCustomResourcesToResources(targetResources, targetCustomResources); 286 287 if (isBlank(name) || isBlank(path) || paramType == null) { 288 if (paramType != RestSearchParameterTypeEnum.COMPOSITE) { 289 return null; 290 } 291 } 292 293 IIdType id = theNextSp.getIdElement(); 294 String uri = ""; 295 ComboSearchParamType unique = null; 296 297 List<Extension> uniqueExts = theNextSp.getExtensionsByUrl(HapiExtensions.EXT_SP_UNIQUE); 298 if (uniqueExts.size() > 0) { 299 IPrimitiveType<?> uniqueExtsValuePrimitive = uniqueExts.get(0).getValueAsPrimitive(); 300 if (uniqueExtsValuePrimitive != null) { 301 if ("true".equalsIgnoreCase(uniqueExtsValuePrimitive.getValueAsString())) { 302 unique = ComboSearchParamType.UNIQUE; 303 } else if ("false".equalsIgnoreCase(uniqueExtsValuePrimitive.getValueAsString())) { 304 unique = ComboSearchParamType.NON_UNIQUE; 305 } 306 } 307 } 308 309 List<RuntimeSearchParam.Component> components = new ArrayList<>(); 310 for (SearchParameter.SearchParameterComponentComponent next : theNextSp.getComponent()) { 311 components.add(new RuntimeSearchParam.Component( 312 next.getExpression(), 313 next.getDefinition() 314 .getReferenceElement() 315 .toUnqualifiedVersionless() 316 .getValue())); 317 } 318 319 return new RuntimeSearchParam( 320 id, 321 uri, 322 name, 323 description, 324 path, 325 paramType, 326 Collections.emptySet(), 327 targetResources, 328 status, 329 unique, 330 components, 331 baseResources); 332 } 333 334 private RuntimeSearchParam canonicalizeSearchParameterR4Plus(IBaseResource theNextSp) { 335 336 String name = myTerser.getSinglePrimitiveValueOrNull(theNextSp, "code"); 337 String description = myTerser.getSinglePrimitiveValueOrNull(theNextSp, "description"); 338 String path = myTerser.getSinglePrimitiveValueOrNull(theNextSp, "expression"); 339 340 Set<String> baseResources = extractR4PlusResources("base", theNextSp); 341 List<String> baseCustomResources = extractR4PlusCustomResourcesFromExtensions( 342 theNextSp, HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE); 343 344 maybeAddCustomResourcesToResources(baseResources, baseCustomResources); 345 346 RestSearchParameterTypeEnum paramType = null; 347 RuntimeSearchParam.RuntimeSearchParamStatusEnum status = null; 348 switch (myTerser.getSinglePrimitiveValue(theNextSp, "type").orElse("")) { 349 case "composite": 350 paramType = RestSearchParameterTypeEnum.COMPOSITE; 351 break; 352 case "date": 353 paramType = RestSearchParameterTypeEnum.DATE; 354 break; 355 case "number": 356 paramType = RestSearchParameterTypeEnum.NUMBER; 357 break; 358 case "quantity": 359 paramType = RestSearchParameterTypeEnum.QUANTITY; 360 break; 361 case "reference": 362 paramType = RestSearchParameterTypeEnum.REFERENCE; 363 break; 364 case "string": 365 paramType = RestSearchParameterTypeEnum.STRING; 366 break; 367 case "token": 368 paramType = RestSearchParameterTypeEnum.TOKEN; 369 break; 370 case "uri": 371 paramType = RestSearchParameterTypeEnum.URI; 372 break; 373 case "special": 374 paramType = RestSearchParameterTypeEnum.SPECIAL; 375 break; 376 } 377 switch (myTerser.getSinglePrimitiveValue(theNextSp, "status").orElse("")) { 378 case "active": 379 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.ACTIVE; 380 break; 381 case "draft": 382 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.DRAFT; 383 break; 384 case "retired": 385 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.RETIRED; 386 break; 387 case "unknown": 388 status = RuntimeSearchParam.RuntimeSearchParamStatusEnum.UNKNOWN; 389 break; 390 } 391 392 Set<String> targetResources = extractR4PlusResources("target", theNextSp); 393 List<String> targetCustomResources = extractR4PlusCustomResourcesFromExtensions( 394 theNextSp, HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_TARGET_RESOURCE); 395 396 maybeAddCustomResourcesToResources(targetResources, targetCustomResources); 397 398 if (isBlank(name) || isBlank(path) || paramType == null) { 399 if ("_text".equals(name) || "_content".equals(name)) { 400 // ok 401 } else if (paramType != RestSearchParameterTypeEnum.COMPOSITE) { 402 return null; 403 } 404 } 405 406 IIdType id = theNextSp.getIdElement(); 407 String uri = myTerser.getSinglePrimitiveValueOrNull(theNextSp, "url"); 408 ComboSearchParamType unique = null; 409 410 String value = ((IBaseHasExtensions) theNextSp) 411 .getExtension().stream() 412 .filter(e -> HapiExtensions.EXT_SP_UNIQUE.equals(e.getUrl())) 413 .filter(t -> t.getValue() instanceof IPrimitiveType) 414 .map(t -> (IPrimitiveType<?>) t.getValue()) 415 .map(t -> t.getValueAsString()) 416 .findFirst() 417 .orElse(""); 418 if ("true".equalsIgnoreCase(value)) { 419 unique = ComboSearchParamType.UNIQUE; 420 } else if ("false".equalsIgnoreCase(value)) { 421 unique = ComboSearchParamType.NON_UNIQUE; 422 } 423 424 List<RuntimeSearchParam.Component> components = new ArrayList<>(); 425 for (IBase next : myTerser.getValues(theNextSp, "component")) { 426 String expression = myTerser.getSinglePrimitiveValueOrNull(next, "expression"); 427 String definition = myTerser.getSinglePrimitiveValueOrNull(next, "definition"); 428 if (startsWith(definition, "/SearchParameter/")) { 429 definition = definition.substring(1); 430 } 431 432 components.add(new RuntimeSearchParam.Component(expression, definition)); 433 } 434 435 return new RuntimeSearchParam( 436 id, 437 uri, 438 name, 439 description, 440 path, 441 paramType, 442 Collections.emptySet(), 443 targetResources, 444 status, 445 unique, 446 components, 447 baseResources); 448 } 449 450 private Set<String> extractR4PlusResources(String thePath, IBaseResource theNextSp) { 451 return myTerser.getValues(theNextSp, thePath, IPrimitiveType.class).stream() 452 .map(IPrimitiveType::getValueAsString) 453 .collect(Collectors.toSet()); 454 } 455 456 /** 457 * Extracts any extensions from the resource and populates an extension field in the 458 */ 459 protected void extractExtensions(IBaseResource theSearchParamResource, RuntimeSearchParam theRuntimeSearchParam) { 460 if (theSearchParamResource instanceof IBaseHasExtensions) { 461 List<? extends IBaseExtension<? extends IBaseExtension, ?>> extensions = 462 (List<? extends IBaseExtension<? extends IBaseExtension, ?>>) 463 ((IBaseHasExtensions) theSearchParamResource).getExtension(); 464 for (IBaseExtension<? extends IBaseExtension, ?> next : extensions) { 465 String nextUrl = next.getUrl(); 466 if (isNotBlank(nextUrl)) { 467 theRuntimeSearchParam.addExtension(nextUrl, next); 468 if (HapiExtensions.EXT_SEARCHPARAM_PHONETIC_ENCODER.equals(nextUrl)) { 469 setEncoder(theRuntimeSearchParam, next.getValue()); 470 } else if (HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN.equals(nextUrl)) { 471 addUpliftRefchain(theRuntimeSearchParam, next); 472 } else if (HapiExtensions.EXT_SEARCHPARAM_ENABLED_FOR_SEARCHING.equals(nextUrl)) { 473 addEnabledForSearching(theRuntimeSearchParam, next.getValue()); 474 } 475 } 476 } 477 } 478 } 479 480 private void addEnabledForSearching(RuntimeSearchParam theRuntimeSearchParam, IBaseDatatype theValue) { 481 if (theValue instanceof IPrimitiveType) { 482 String stringValue = ((IPrimitiveType<?>) theValue).getValueAsString(); 483 boolean enabledForSearching = Boolean.parseBoolean(stringValue); 484 theRuntimeSearchParam.setEnabledForSearching(enabledForSearching); 485 } 486 } 487 488 @SuppressWarnings("unchecked") 489 private void addUpliftRefchain( 490 RuntimeSearchParam theRuntimeSearchParam, IBaseExtension<? extends IBaseExtension, ?> theExtension) { 491 String code = ExtensionUtil.extractChildPrimitiveExtensionValue( 492 theExtension, HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN_PARAM_CODE); 493 String elementName = ExtensionUtil.extractChildPrimitiveExtensionValue( 494 theExtension, HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN_ELEMENT_NAME); 495 if (isNotBlank(code)) { 496 theRuntimeSearchParam.addUpliftRefchain(code, elementName); 497 } 498 } 499 500 private void setEncoder(RuntimeSearchParam theRuntimeSearchParam, IBaseDatatype theValue) { 501 if (theValue instanceof IPrimitiveType) { 502 String stringValue = ((IPrimitiveType<?>) theValue).getValueAsString(); 503 504 // every string creates a completely new encoder wrapper. 505 // this is fine, because the runtime search parameters are constructed at startup 506 // for every saved value 507 IPhoneticEncoder encoder = PhoneticEncoderUtil.getEncoder(stringValue); 508 if (encoder != null) { 509 theRuntimeSearchParam.setPhoneticEncoder(encoder); 510 } else { 511 ourLog.error("Invalid PhoneticEncoderEnum value '" + stringValue + "'"); 512 } 513 } 514 } 515 516 private List<String> extractDstu2CustomResourcesFromExtensions( 517 ca.uhn.fhir.model.dstu2.resource.SearchParameter theSearchParameter, String theExtensionUrl) { 518 519 List<ExtensionDt> customSpExtensionDt = theSearchParameter.getUndeclaredExtensionsByUrl(theExtensionUrl); 520 521 return customSpExtensionDt.stream() 522 .map(theExtensionDt -> theExtensionDt.getValueAsPrimitive().getValueAsString()) 523 .filter(StringUtils::isNotBlank) 524 .collect(Collectors.toList()); 525 } 526 527 private List<String> extractDstu3CustomResourcesFromExtensions( 528 org.hl7.fhir.dstu3.model.SearchParameter theSearchParameter, String theExtensionUrl) { 529 530 List<Extension> customSpExtensions = theSearchParameter.getExtensionsByUrl(theExtensionUrl); 531 532 return customSpExtensions.stream() 533 .map(theExtension -> theExtension.getValueAsPrimitive().getValueAsString()) 534 .filter(StringUtils::isNotBlank) 535 .collect(Collectors.toList()); 536 } 537 538 private List<String> extractR4PlusCustomResourcesFromExtensions( 539 IBaseResource theSearchParameter, String theExtensionUrl) { 540 541 List<String> retVal = new ArrayList<>(); 542 543 if (theSearchParameter instanceof IBaseHasExtensions) { 544 ((IBaseHasExtensions) theSearchParameter) 545 .getExtension().stream() 546 .filter(t -> theExtensionUrl.equals(t.getUrl())) 547 .filter(t -> t.getValue() instanceof IPrimitiveType) 548 .map(t -> ((IPrimitiveType<?>) t.getValue())) 549 .map(IPrimitiveType::getValueAsString) 550 .filter(StringUtils::isNotBlank) 551 .forEach(retVal::add); 552 } 553 554 return retVal; 555 } 556 557 private <T extends Collection<String>> void maybeAddCustomResourcesToResources( 558 T theResources, List<String> theCustomResources) { 559 // SearchParameter base and target components require strict binding to ResourceType for dstu[2|3], R4, R4B 560 // and to Version Independent Resource Types for R5. 561 // 562 // To handle custom resources, we set a placeholder of type 'Resource' in the base or target component and 563 // define 564 // the custom resource by adding a corresponding extension with url 565 // HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE 566 // or HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_TARGET_RESOURCE with the name of the custom resource. 567 // 568 // To provide a base/target list that contains both the resources and customResources, we need to remove the 569 // placeholders 570 // from the theResources and add theCustomResources. 571 572 if (!theCustomResources.isEmpty()) { 573 theResources.removeAll(Collections.singleton("Resource")); 574 theResources.addAll(theCustomResources); 575 } 576 } 577}