001/* 002 * #%L 003 * HAPI FHIR - Server Framework 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.rest.server; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.FhirVersionEnum; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.HookParams; 026import ca.uhn.fhir.interceptor.api.Pointcut; 027import ca.uhn.fhir.model.api.IResource; 028import ca.uhn.fhir.model.api.Include; 029import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum; 030import ca.uhn.fhir.model.primitive.InstantDt; 031import ca.uhn.fhir.parser.IParser; 032import ca.uhn.fhir.rest.api.BundleLinks; 033import ca.uhn.fhir.rest.api.Constants; 034import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum; 035import ca.uhn.fhir.rest.api.EncodingEnum; 036import ca.uhn.fhir.rest.api.PreferHandlingEnum; 037import ca.uhn.fhir.rest.api.PreferHeader; 038import ca.uhn.fhir.rest.api.PreferReturnEnum; 039import ca.uhn.fhir.rest.api.RequestTypeEnum; 040import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 041import ca.uhn.fhir.rest.api.SummaryEnum; 042import ca.uhn.fhir.rest.api.server.IRestfulResponse; 043import ca.uhn.fhir.rest.api.server.IRestfulServer; 044import ca.uhn.fhir.rest.api.server.RequestDetails; 045import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 046import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 047import ca.uhn.fhir.rest.server.method.ElementsParameter; 048import ca.uhn.fhir.rest.server.method.SummaryEnumParameter; 049import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 050import ca.uhn.fhir.util.BinaryUtil; 051import ca.uhn.fhir.util.DateUtils; 052import ca.uhn.fhir.util.UrlUtil; 053import com.google.common.collect.Maps; 054import com.google.common.collect.Sets; 055import jakarta.annotation.Nonnull; 056import jakarta.annotation.Nullable; 057import jakarta.servlet.http.HttpServletRequest; 058import org.apache.commons.lang3.StringUtils; 059import org.apache.commons.lang3.math.NumberUtils; 060import org.hl7.fhir.instance.model.api.IAnyResource; 061import org.hl7.fhir.instance.model.api.IBaseBinary; 062import org.hl7.fhir.instance.model.api.IBaseReference; 063import org.hl7.fhir.instance.model.api.IBaseResource; 064import org.hl7.fhir.instance.model.api.IDomainResource; 065import org.hl7.fhir.instance.model.api.IIdType; 066import org.hl7.fhir.instance.model.api.IPrimitiveType; 067 068import java.io.IOException; 069import java.io.OutputStream; 070import java.io.Writer; 071import java.util.Arrays; 072import java.util.Collections; 073import java.util.Date; 074import java.util.EnumSet; 075import java.util.Enumeration; 076import java.util.HashMap; 077import java.util.HashSet; 078import java.util.Iterator; 079import java.util.List; 080import java.util.Map; 081import java.util.Objects; 082import java.util.Set; 083import java.util.StringTokenizer; 084import java.util.TreeSet; 085import java.util.regex.Matcher; 086import java.util.regex.Pattern; 087import java.util.stream.Collectors; 088 089import static org.apache.commons.lang3.StringUtils.isBlank; 090import static org.apache.commons.lang3.StringUtils.isNotBlank; 091import static org.apache.commons.lang3.StringUtils.replace; 092import static org.apache.commons.lang3.StringUtils.trim; 093 094public class RestfulServerUtils { 095 static final Pattern ACCEPT_HEADER_PATTERN = 096 Pattern.compile("\\s*([a-zA-Z0-9+.*/-]+)\\s*(;\\s*([a-zA-Z]+)\\s*=\\s*([a-zA-Z0-9.]+)\\s*)?(,?)"); 097 098 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(RestfulServerUtils.class); 099 100 private static final HashSet<String> TEXT_ENCODE_ELEMENTS = 101 new HashSet<>(Arrays.asList("*.text", "*.id", "*.meta", "*.(mandatory)")); 102 private static Map<FhirVersionEnum, FhirContext> myFhirContextMap = Collections.synchronizedMap(new HashMap<>()); 103 private static EnumSet<RestOperationTypeEnum> ourOperationsWhichAllowPreferHeader = 104 EnumSet.of(RestOperationTypeEnum.CREATE, RestOperationTypeEnum.UPDATE, RestOperationTypeEnum.PATCH); 105 106 @SuppressWarnings("EnumSwitchStatementWhichMissesCases") 107 public static void configureResponseParser(RequestDetails theRequestDetails, IParser parser) { 108 // Pretty print 109 boolean prettyPrint = RestfulServerUtils.prettyPrintResponse(theRequestDetails.getServer(), theRequestDetails); 110 111 parser.setPrettyPrint(prettyPrint); 112 parser.setServerBaseUrl(theRequestDetails.getFhirServerBase()); 113 114 // Summary mode 115 Set<SummaryEnum> summaryMode = RestfulServerUtils.determineSummaryMode(theRequestDetails); 116 117 // _elements 118 Set<String> elements = ElementsParameter.getElementsValueOrNull(theRequestDetails, false); 119 if (elements != null && !summaryMode.equals(Collections.singleton(SummaryEnum.FALSE))) { 120 throw new InvalidRequestException(Msg.code(304) + "Cannot combine the " + Constants.PARAM_SUMMARY + " and " 121 + Constants.PARAM_ELEMENTS + " parameters"); 122 } 123 124 // _elements:exclude 125 Set<String> elementsExclude = ElementsParameter.getElementsValueOrNull(theRequestDetails, true); 126 if (elementsExclude != null) { 127 parser.setDontEncodeElements(elementsExclude); 128 } 129 130 boolean summaryModeCount = summaryMode.contains(SummaryEnum.COUNT) && summaryMode.size() == 1; 131 if (!summaryModeCount) { 132 String[] countParam = theRequestDetails.getParameters().get(Constants.PARAM_COUNT); 133 if (countParam != null && countParam.length > 0) { 134 summaryModeCount = "0".equalsIgnoreCase(countParam[0]); 135 } 136 } 137 138 if (summaryModeCount) { 139 parser.setEncodeElements(Sets.newHashSet("Bundle.total", "Bundle.type")); 140 } else if (summaryMode.contains(SummaryEnum.TEXT) && summaryMode.size() == 1) { 141 parser.setEncodeElements(TEXT_ENCODE_ELEMENTS); 142 parser.setEncodeElementsAppliesToChildResourcesOnly(true); 143 } else { 144 parser.setSuppressNarratives(summaryMode.contains(SummaryEnum.DATA)); 145 parser.setSummaryMode(summaryMode.contains(SummaryEnum.TRUE)); 146 } 147 148 if (elements != null && elements.size() > 0) { 149 String elementsAppliesTo = "*"; 150 if (isNotBlank(theRequestDetails.getResourceName())) { 151 elementsAppliesTo = theRequestDetails.getResourceName(); 152 } 153 154 Set<String> newElements = new HashSet<>(); 155 for (String next : elements) { 156 if (isNotBlank(next)) { 157 if (Character.isUpperCase(next.charAt(0))) { 158 newElements.add(next); 159 } else { 160 newElements.add(elementsAppliesTo + "." + next); 161 } 162 } 163 } 164 165 /* 166 * We try to be smart about what the user is asking for 167 * when they include an _elements parameter. If we're responding 168 * to something that returns a Bundle (e.g. a search) we assume 169 * the elements don't apply to the Bundle itself, unless 170 * the client has explicitly scoped the Bundle 171 * (i.e. with Bundle.total or something like that) 172 */ 173 boolean haveExplicitBundleElement = false; 174 for (String next : newElements) { 175 if (next.startsWith("Bundle.")) { 176 haveExplicitBundleElement = true; 177 break; 178 } 179 } 180 181 if (theRequestDetails.getRestOperationType() != null) { 182 switch (theRequestDetails.getRestOperationType()) { 183 case SEARCH_SYSTEM: 184 case SEARCH_TYPE: 185 case HISTORY_SYSTEM: 186 case HISTORY_TYPE: 187 case HISTORY_INSTANCE: 188 case GET_PAGE: 189 if (!haveExplicitBundleElement) { 190 parser.setEncodeElementsAppliesToChildResourcesOnly(true); 191 } 192 break; 193 default: 194 break; 195 } 196 } 197 198 parser.setEncodeElements(newElements); 199 } 200 } 201 202 public static String createLinkSelf(String theServerBase, RequestDetails theRequest) { 203 return createLinkSelfWithoutGivenParameters(theServerBase, theRequest, null); 204 } 205 206 /** 207 * This function will create a self link but omit any parameters passed in via the excludedParameterNames list. 208 */ 209 public static String createLinkSelfWithoutGivenParameters( 210 String theServerBase, RequestDetails theRequest, List<String> excludedParameterNames) { 211 String tenantId = StringUtils.defaultString(theRequest.getTenantId()); 212 String requestPath = StringUtils.defaultString(theRequest.getRequestPath()); 213 214 StringBuilder b = new StringBuilder(); 215 b.append(theServerBase); 216 requestPath = StringUtils.substringAfter(requestPath, tenantId); 217 218 if (isNotBlank(requestPath)) { 219 requestPath = StringUtils.prependIfMissing(requestPath, "/"); 220 } 221 222 b.append(requestPath); 223 224 // For POST the URL parameters get jumbled with the post body parameters so don't include them, they might be 225 // huge 226 if (theRequest.getRequestType() == RequestTypeEnum.GET) { 227 boolean first = true; 228 Map<String, String[]> parameters = theRequest.getParameters(); 229 for (String nextParamName : new TreeSet<>(parameters.keySet())) { 230 if (excludedParameterNames == null || !excludedParameterNames.contains(nextParamName)) { 231 for (String nextParamValue : parameters.get(nextParamName)) { 232 if (first) { 233 b.append('?'); 234 first = false; 235 } else { 236 b.append('&'); 237 } 238 b.append(UrlUtil.escapeUrlParam(nextParamName)); 239 b.append('='); 240 b.append(UrlUtil.escapeUrlParam(nextParamValue)); 241 } 242 } 243 } 244 } 245 return b.toString(); 246 } 247 248 public static String createOffsetPagingLink( 249 BundleLinks theBundleLinks, 250 String requestPath, 251 String tenantId, 252 Integer theOffset, 253 Integer theCount, 254 Map<String, String[]> theRequestParameters) { 255 StringBuilder b = new StringBuilder(); 256 b.append(theBundleLinks.serverBase); 257 258 if (isNotBlank(requestPath)) { 259 b.append('/'); 260 if (isNotBlank(tenantId) && requestPath.startsWith(tenantId + "/")) { 261 b.append(requestPath.substring(tenantId.length() + 1)); 262 } else { 263 b.append(requestPath); 264 } 265 } 266 267 Map<String, String[]> params = Maps.newLinkedHashMap(theRequestParameters); 268 params.put(Constants.PARAM_OFFSET, new String[] {String.valueOf(theOffset)}); 269 params.put(Constants.PARAM_COUNT, new String[] {String.valueOf(theCount)}); 270 271 boolean first = true; 272 for (String nextParamName : new TreeSet<>(params.keySet())) { 273 for (String nextParamValue : params.get(nextParamName)) { 274 if (first) { 275 b.append('?'); 276 first = false; 277 } else { 278 b.append('&'); 279 } 280 b.append(UrlUtil.escapeUrlParam(nextParamName)); 281 b.append('='); 282 b.append(UrlUtil.escapeUrlParam(nextParamValue)); 283 } 284 } 285 286 return b.toString(); 287 } 288 289 public static String createPagingLink( 290 BundleLinks theBundleLinks, 291 RequestDetails theRequestDetails, 292 String theSearchId, 293 int theOffset, 294 int theCount, 295 Map<String, String[]> theRequestParameters) { 296 return createPagingLink( 297 theBundleLinks, theRequestDetails, theSearchId, theOffset, theCount, theRequestParameters, null); 298 } 299 300 public static String createPagingLink( 301 BundleLinks theBundleLinks, 302 RequestDetails theRequestDetails, 303 String theSearchId, 304 String thePageId, 305 Map<String, String[]> theRequestParameters) { 306 return createPagingLink( 307 theBundleLinks, theRequestDetails, theSearchId, null, null, theRequestParameters, thePageId); 308 } 309 310 private static String createPagingLink( 311 BundleLinks theBundleLinks, 312 RequestDetails theRequestDetails, 313 String theSearchId, 314 Integer theOffset, 315 Integer theCount, 316 Map<String, String[]> theRequestParameters, 317 String thePageId) { 318 319 String serverBase = theRequestDetails.getFhirServerBase(); 320 321 StringBuilder b = new StringBuilder(); 322 b.append(serverBase); 323 b.append('?'); 324 b.append(Constants.PARAM_PAGINGACTION); 325 b.append('='); 326 b.append(UrlUtil.escapeUrlParam(theSearchId)); 327 328 if (theOffset != null) { 329 b.append('&'); 330 b.append(Constants.PARAM_PAGINGOFFSET); 331 b.append('='); 332 b.append(theOffset); 333 } 334 if (theCount != null) { 335 b.append('&'); 336 b.append(Constants.PARAM_COUNT); 337 b.append('='); 338 b.append(theCount); 339 } 340 if (isNotBlank(thePageId)) { 341 b.append('&'); 342 b.append(Constants.PARAM_PAGEID); 343 b.append('='); 344 b.append(UrlUtil.escapeUrlParam(thePageId)); 345 } 346 String[] strings = theRequestParameters.get(Constants.PARAM_FORMAT); 347 if (strings != null && strings.length > 0) { 348 b.append('&'); 349 b.append(Constants.PARAM_FORMAT); 350 b.append('='); 351 String format = strings[0]; 352 format = replace(format, " ", "+"); 353 b.append(UrlUtil.escapeUrlParam(format)); 354 } 355 if (theBundleLinks.prettyPrint) { 356 b.append('&'); 357 b.append(Constants.PARAM_PRETTY); 358 b.append('='); 359 b.append(Constants.PARAM_PRETTY_VALUE_TRUE); 360 } 361 362 if (theBundleLinks.getIncludes() != null) { 363 for (Include nextInclude : theBundleLinks.getIncludes()) { 364 if (isNotBlank(nextInclude.getValue())) { 365 b.append('&'); 366 b.append(Constants.PARAM_INCLUDE); 367 b.append('='); 368 b.append(UrlUtil.escapeUrlParam(nextInclude.getValue())); 369 } 370 } 371 } 372 373 if (theBundleLinks.bundleType != null) { 374 b.append('&'); 375 b.append(Constants.PARAM_BUNDLETYPE); 376 b.append('='); 377 b.append(theBundleLinks.bundleType.getCode()); 378 } 379 380 // _elements 381 Set<String> elements = ElementsParameter.getElementsValueOrNull(theRequestDetails, false); 382 if (elements != null) { 383 b.append('&'); 384 b.append(Constants.PARAM_ELEMENTS); 385 b.append('='); 386 String nextValue = 387 elements.stream().sorted().map(UrlUtil::escapeUrlParam).collect(Collectors.joining(",")); 388 b.append(nextValue); 389 } 390 391 // _elements:exclude 392 if (theRequestDetails.getServer().getElementsSupport() == ElementsSupportEnum.EXTENDED) { 393 Set<String> elementsExclude = ElementsParameter.getElementsValueOrNull(theRequestDetails, true); 394 if (elementsExclude != null) { 395 b.append('&'); 396 b.append(Constants.PARAM_ELEMENTS + Constants.PARAM_ELEMENTS_EXCLUDE_MODIFIER); 397 b.append('='); 398 String nextValue = elementsExclude.stream() 399 .sorted() 400 .map(UrlUtil::escapeUrlParam) 401 .collect(Collectors.joining(",")); 402 b.append(nextValue); 403 } 404 } 405 406 return b.toString(); 407 } 408 409 @Nullable 410 public static EncodingEnum determineRequestEncodingNoDefault(RequestDetails theReq) { 411 return determineRequestEncodingNoDefault(theReq, false); 412 } 413 414 @Nullable 415 public static EncodingEnum determineRequestEncodingNoDefault(RequestDetails theReq, boolean theStrict) { 416 ResponseEncoding retVal = determineRequestEncodingNoDefaultReturnRE(theReq, theStrict); 417 if (retVal == null) { 418 return null; 419 } 420 return retVal.getEncoding(); 421 } 422 423 private static ResponseEncoding determineRequestEncodingNoDefaultReturnRE( 424 RequestDetails theReq, boolean theStrict) { 425 ResponseEncoding retVal = null; 426 List<String> headers = theReq.getHeaders(Constants.HEADER_CONTENT_TYPE); 427 if (headers != null) { 428 Iterator<String> acceptValues = headers.iterator(); 429 if (acceptValues != null) { 430 while (acceptValues.hasNext() && retVal == null) { 431 String nextAcceptHeaderValue = acceptValues.next(); 432 if (nextAcceptHeaderValue != null && isNotBlank(nextAcceptHeaderValue)) { 433 for (String nextPart : nextAcceptHeaderValue.split(",")) { 434 int scIdx = nextPart.indexOf(';'); 435 if (scIdx == 0) { 436 continue; 437 } 438 if (scIdx != -1) { 439 nextPart = nextPart.substring(0, scIdx); 440 } 441 nextPart = nextPart.trim(); 442 EncodingEnum encoding; 443 if (theStrict) { 444 encoding = EncodingEnum.forContentTypeStrict(nextPart); 445 } else { 446 encoding = EncodingEnum.forContentType(nextPart); 447 } 448 if (encoding != null) { 449 retVal = new ResponseEncoding(theReq.getServer().getFhirContext(), encoding, nextPart); 450 break; 451 } 452 } 453 } 454 } 455 } 456 } 457 return retVal; 458 } 459 460 /** 461 * Returns null if the request doesn't express that it wants FHIR. If it expresses that it wants XML and JSON 462 * equally, returns thePrefer. 463 */ 464 public static ResponseEncoding determineResponseEncodingNoDefault(RequestDetails theReq, EncodingEnum thePrefer) { 465 return determineResponseEncodingNoDefault(theReq, thePrefer, null); 466 } 467 468 /** 469 * Try to determing the response content type, given the request Accept header and 470 * _format parameter. If a value is provided to thePreferContents, we'll 471 * prefer to return that value over the native FHIR value. 472 */ 473 public static ResponseEncoding determineResponseEncodingNoDefault( 474 RequestDetails theReq, EncodingEnum thePrefer, String thePreferContentType) { 475 String[] format = theReq.getParameters().get(Constants.PARAM_FORMAT); 476 if (format != null) { 477 for (String nextFormat : format) { 478 EncodingEnum retVal = EncodingEnum.forContentType(nextFormat); 479 if (retVal != null) { 480 return new ResponseEncoding(theReq.getServer().getFhirContext(), retVal, nextFormat); 481 } 482 } 483 } 484 485 /* 486 * Some browsers (e.g. FF) request "application/xml" in their Accept header, 487 * and we generally want to treat this as a preference for FHIR XML even if 488 * it's not the FHIR version of the CT, which should be "application/xml+fhir". 489 * 490 * When we're serving up Binary resources though, we are a bit more strict, 491 * since Binary is supposed to use native content types unless the client has 492 * explicitly requested FHIR. 493 */ 494 boolean strict = false; 495 if ("Binary".equals(theReq.getResourceName())) { 496 strict = true; 497 } 498 499 /* 500 * The Accept header is kind of ridiculous, e.g. 501 */ 502 // text/xml, application/xml, application/xhtml+xml, text/html;q=0.9, text/plain;q=0.8, image/png, */*;q=0.5 503 504 List<String> acceptValues = theReq.getHeaders(Constants.HEADER_ACCEPT); 505 float bestQ = -1f; 506 ResponseEncoding retVal = null; 507 if (acceptValues != null) { 508 for (String nextAcceptHeaderValue : acceptValues) { 509 StringTokenizer tok = new StringTokenizer(nextAcceptHeaderValue, ","); 510 while (tok.hasMoreTokens()) { 511 String nextToken = tok.nextToken(); 512 int startSpaceIndex = -1; 513 for (int i = 0; i < nextToken.length(); i++) { 514 if (nextToken.charAt(i) != ' ') { 515 startSpaceIndex = i; 516 break; 517 } 518 } 519 520 if (startSpaceIndex == -1) { 521 continue; 522 } 523 524 int endSpaceIndex = -1; 525 for (int i = startSpaceIndex; i < nextToken.length(); i++) { 526 if (nextToken.charAt(i) == ' ' || nextToken.charAt(i) == ';') { 527 endSpaceIndex = i; 528 break; 529 } 530 } 531 532 float q = 1.0f; 533 ResponseEncoding encoding; 534 if (endSpaceIndex == -1) { 535 if (startSpaceIndex == 0) { 536 encoding = getEncodingForContentType( 537 theReq.getServer().getFhirContext(), strict, nextToken, thePreferContentType); 538 } else { 539 encoding = getEncodingForContentType( 540 theReq.getServer().getFhirContext(), 541 strict, 542 nextToken.substring(startSpaceIndex), 543 thePreferContentType); 544 } 545 } else { 546 encoding = getEncodingForContentType( 547 theReq.getServer().getFhirContext(), 548 strict, 549 nextToken.substring(startSpaceIndex, endSpaceIndex), 550 thePreferContentType); 551 String remaining = nextToken.substring(endSpaceIndex + 1); 552 StringTokenizer qualifierTok = new StringTokenizer(remaining, ";"); 553 while (qualifierTok.hasMoreTokens()) { 554 String nextQualifier = qualifierTok.nextToken(); 555 int equalsIndex = nextQualifier.indexOf('='); 556 if (equalsIndex != -1) { 557 String nextQualifierKey = 558 nextQualifier.substring(0, equalsIndex).trim(); 559 String nextQualifierValue = nextQualifier 560 .substring(equalsIndex + 1, nextQualifier.length()) 561 .trim(); 562 if (nextQualifierKey.equals("q")) { 563 try { 564 q = Float.parseFloat(nextQualifierValue); 565 q = Math.max(q, 0.0f); 566 } catch (NumberFormatException e) { 567 ourLog.debug("Invalid Accept header q value: {}", nextQualifierValue); 568 } 569 } 570 } 571 } 572 } 573 574 if (encoding != null) { 575 if (q > bestQ || (q == bestQ && encoding.getEncoding() == thePrefer)) { 576 retVal = encoding; 577 bestQ = q; 578 } 579 } 580 } 581 } 582 } 583 584 /* 585 * If the client hasn't given any indication about which response 586 * encoding they want, let's try the request encoding in case that 587 * is useful (basically this catches the case where the request 588 * has a Content-Type header but not an Accept header) 589 */ 590 if (retVal == null) { 591 retVal = determineRequestEncodingNoDefaultReturnRE(theReq, strict); 592 } 593 594 return retVal; 595 } 596 597 /** 598 * Determine whether a response should be given in JSON or XML format based on the incoming HttpServletRequest's 599 * <code>"_format"</code> parameter and <code>"Accept:"</code> HTTP header. 600 */ 601 public static ResponseEncoding determineResponseEncodingWithDefault(RequestDetails theReq) { 602 ResponseEncoding retVal = 603 determineResponseEncodingNoDefault(theReq, theReq.getServer().getDefaultResponseEncoding()); 604 if (retVal == null) { 605 retVal = new ResponseEncoding( 606 theReq.getServer().getFhirContext(), theReq.getServer().getDefaultResponseEncoding(), null); 607 } 608 return retVal; 609 } 610 611 @Nonnull 612 public static Set<SummaryEnum> determineSummaryMode(RequestDetails theRequest) { 613 Map<String, String[]> requestParams = theRequest.getParameters(); 614 615 Set<SummaryEnum> retVal = SummaryEnumParameter.getSummaryValueOrNull(theRequest); 616 617 if (retVal == null) { 618 /* 619 * HAPI originally supported a custom parameter called _narrative, but this has been superceded by an official 620 * parameter called _summary 621 */ 622 String[] narrative = requestParams.get(Constants.PARAM_NARRATIVE); 623 if (narrative != null && narrative.length > 0) { 624 try { 625 NarrativeModeEnum narrativeMode = NarrativeModeEnum.valueOfCaseInsensitive(narrative[0]); 626 switch (narrativeMode) { 627 case NORMAL: 628 retVal = Collections.singleton(SummaryEnum.FALSE); 629 break; 630 case ONLY: 631 retVal = Collections.singleton(SummaryEnum.TEXT); 632 break; 633 case SUPPRESS: 634 retVal = Collections.singleton(SummaryEnum.DATA); 635 break; 636 } 637 } catch (IllegalArgumentException e) { 638 ourLog.debug("Invalid {} parameter: {}", Constants.PARAM_NARRATIVE, narrative[0]); 639 } 640 } 641 } 642 if (retVal == null) { 643 retVal = Collections.singleton(SummaryEnum.FALSE); 644 } 645 646 return retVal; 647 } 648 649 public static Integer extractCountParameter(RequestDetails theRequest) { 650 return RestfulServerUtils.tryToExtractNamedParameter(theRequest, Constants.PARAM_COUNT); 651 } 652 653 public static Integer extractOffsetParameter(RequestDetails theRequest) { 654 return RestfulServerUtils.tryToExtractNamedParameter(theRequest, Constants.PARAM_OFFSET); 655 } 656 657 public static IPrimitiveType<Date> extractLastUpdatedFromResource(IBaseResource theResource) { 658 IPrimitiveType<Date> lastUpdated = null; 659 if (theResource instanceof IResource) { 660 lastUpdated = ResourceMetadataKeyEnum.UPDATED.get((IResource) theResource); 661 } else if (theResource instanceof IAnyResource) { 662 lastUpdated = new InstantDt(theResource.getMeta().getLastUpdated()); 663 } 664 return lastUpdated; 665 } 666 667 public static IIdType fullyQualifyResourceIdOrReturnNull( 668 IRestfulServerDefaults theServer, IBaseResource theResource, String theServerBase, IIdType theResourceId) { 669 IIdType retVal = null; 670 if (theResourceId.hasIdPart() && isNotBlank(theServerBase)) { 671 String resName = theResourceId.getResourceType(); 672 if (theResource != null && isBlank(resName)) { 673 FhirContext context = theServer.getFhirContext(); 674 context = getContextForVersion(context, theResource.getStructureFhirVersionEnum()); 675 resName = context.getResourceType(theResource); 676 } 677 if (isNotBlank(resName)) { 678 retVal = theResourceId.withServerBase(theServerBase, resName); 679 } 680 } 681 return retVal; 682 } 683 684 private static FhirContext getContextForVersion(FhirContext theContext, FhirVersionEnum theForVersion) { 685 FhirContext context = theContext; 686 if (context.getVersion().getVersion() != theForVersion) { 687 context = myFhirContextMap.get(theForVersion); 688 if (context == null) { 689 context = FhirContext.forVersion(theForVersion); 690 myFhirContextMap.put(theForVersion, context); 691 } 692 } 693 return context; 694 } 695 696 private static ResponseEncoding getEncodingForContentType( 697 FhirContext theFhirContext, boolean theStrict, String theContentType, String thePreferContentType) { 698 EncodingEnum encoding; 699 if (theStrict) { 700 encoding = EncodingEnum.forContentTypeStrict(theContentType); 701 } else { 702 encoding = EncodingEnum.forContentType(theContentType); 703 } 704 if (isNotBlank(thePreferContentType)) { 705 if (thePreferContentType.equals(theContentType)) { 706 return new ResponseEncoding(theFhirContext, encoding, theContentType); 707 } 708 } 709 if (encoding == null) { 710 return null; 711 } 712 return new ResponseEncoding(theFhirContext, encoding, theContentType); 713 } 714 715 public static IParser getNewParser( 716 FhirContext theContext, FhirVersionEnum theForVersion, RequestDetails theRequestDetails) { 717 FhirContext context = getContextForVersion(theContext, theForVersion); 718 719 // Determine response encoding 720 EncodingEnum responseEncoding = RestfulServerUtils.determineResponseEncodingWithDefault(theRequestDetails) 721 .getEncoding(); 722 IParser parser; 723 switch (responseEncoding) { 724 case JSON: 725 parser = context.newJsonParser(); 726 break; 727 case RDF: 728 parser = context.newRDFParser(); 729 break; 730 case XML: 731 default: 732 parser = context.newXmlParser(); 733 break; 734 } 735 736 configureResponseParser(theRequestDetails, parser); 737 738 return parser; 739 } 740 741 public static Set<String> parseAcceptHeaderAndReturnHighestRankedOptions(HttpServletRequest theRequest) { 742 Set<String> retVal = new HashSet<String>(); 743 744 Enumeration<String> acceptValues = theRequest.getHeaders(Constants.HEADER_ACCEPT); 745 if (acceptValues != null) { 746 float bestQ = -1f; 747 while (acceptValues.hasMoreElements()) { 748 String nextAcceptHeaderValue = acceptValues.nextElement(); 749 Matcher m = ACCEPT_HEADER_PATTERN.matcher(nextAcceptHeaderValue); 750 float q = 1.0f; 751 while (m.find()) { 752 String contentTypeGroup = m.group(1); 753 if (isNotBlank(contentTypeGroup)) { 754 755 String name = m.group(3); 756 String value = m.group(4); 757 if (name != null && value != null) { 758 if ("q".equals(name)) { 759 try { 760 q = Float.parseFloat(value); 761 q = Math.max(q, 0.0f); 762 } catch (NumberFormatException e) { 763 ourLog.debug("Invalid Accept header q value: {}", value); 764 } 765 } 766 } 767 768 if (q > bestQ) { 769 retVal.clear(); 770 bestQ = q; 771 } 772 773 if (q == bestQ) { 774 retVal.add(contentTypeGroup.trim()); 775 } 776 } 777 778 if (!",".equals(m.group(5))) { 779 break; 780 } 781 } 782 } 783 } 784 785 return retVal; 786 } 787 788 public static boolean respectPreferHeader(RestOperationTypeEnum theRestOperationType) { 789 return ourOperationsWhichAllowPreferHeader.contains(theRestOperationType); 790 } 791 792 @Nonnull 793 public static PreferHeader parsePreferHeader(IRestfulServer<?> theServer, String theValue) { 794 PreferHeader retVal = new PreferHeader(); 795 796 if (isNotBlank(theValue)) { 797 StringTokenizer tok = new StringTokenizer(theValue, ";,"); 798 while (tok.hasMoreTokens()) { 799 String next = trim(tok.nextToken()); 800 int eqIndex = next.indexOf('='); 801 802 String key; 803 String value; 804 if (eqIndex == -1 || eqIndex >= next.length() - 2) { 805 key = next; 806 value = ""; 807 } else { 808 key = next.substring(0, eqIndex).trim(); 809 value = next.substring(eqIndex + 1).trim(); 810 } 811 812 if (key.equals(Constants.HEADER_PREFER_RETURN)) { 813 814 value = cleanUpValue(value); 815 retVal.setReturn(PreferReturnEnum.fromHeaderValue(value)); 816 817 } else if (key.equals(Constants.HEADER_PREFER_HANDLING)) { 818 819 value = cleanUpValue(value); 820 retVal.setHanding(PreferHandlingEnum.fromHeaderValue(value)); 821 822 } else if (key.equals(Constants.HEADER_PREFER_RESPOND_ASYNC)) { 823 824 retVal.setRespondAsync(true); 825 } 826 } 827 } 828 829 if (retVal.getReturn() == null && theServer != null && theServer.getDefaultPreferReturn() != null) { 830 retVal.setReturn(theServer.getDefaultPreferReturn()); 831 } 832 833 return retVal; 834 } 835 836 private static String cleanUpValue(String value) { 837 if (value.length() < 2) { 838 value = ""; 839 } 840 if ('"' == value.charAt(0) && '"' == value.charAt(value.length() - 1)) { 841 value = value.substring(1, value.length() - 1); 842 } 843 return value; 844 } 845 846 public static boolean prettyPrintResponse(IRestfulServerDefaults theServer, RequestDetails theRequest) { 847 Map<String, String[]> requestParams = theRequest.getParameters(); 848 String[] pretty = requestParams.get(Constants.PARAM_PRETTY); 849 boolean prettyPrint; 850 if (pretty != null && pretty.length > 0) { 851 prettyPrint = Constants.PARAM_PRETTY_VALUE_TRUE.equals(pretty[0]); 852 } else { 853 prettyPrint = theServer.isDefaultPrettyPrint(); 854 List<String> acceptValues = theRequest.getHeaders(Constants.HEADER_ACCEPT); 855 if (acceptValues != null) { 856 for (String nextAcceptHeaderValue : acceptValues) { 857 if (nextAcceptHeaderValue.contains("pretty=true")) { 858 prettyPrint = true; 859 } 860 } 861 } 862 } 863 return prettyPrint; 864 } 865 866 public static Object streamResponseAsResource( 867 IRestfulServerDefaults theServer, 868 IBaseResource theResource, 869 Set<SummaryEnum> theSummaryMode, 870 int theStatusCode, 871 boolean theAddContentLocationHeader, 872 boolean respondGzip, 873 RequestDetails theRequestDetails) 874 throws IOException { 875 return streamResponseAsResource( 876 theServer, 877 theResource, 878 theSummaryMode, 879 theStatusCode, 880 theAddContentLocationHeader, 881 respondGzip, 882 theRequestDetails, 883 null, 884 null); 885 } 886 887 public static Object streamResponseAsResource( 888 IRestfulServerDefaults theServer, 889 IBaseResource theResource, 890 Set<SummaryEnum> theSummaryMode, 891 int theStatusCode, 892 boolean theAddContentLocationHeader, 893 boolean respondGzip, 894 RequestDetails theRequestDetails, 895 IIdType theOperationResourceId, 896 IPrimitiveType<Date> theOperationResourceLastUpdated) 897 throws IOException { 898 IRestfulResponse response = theRequestDetails.getResponse(); 899 900 // Determine response encoding 901 ResponseEncoding responseEncoding = RestfulServerUtils.determineResponseEncodingNoDefault( 902 theRequestDetails, theServer.getDefaultResponseEncoding()); 903 904 String serverBase = theRequestDetails.getFhirServerBase(); 905 IIdType fullId = null; 906 if (theOperationResourceId != null) { 907 fullId = theOperationResourceId; 908 } else if (theResource != null) { 909 if (theResource.getIdElement() != null) { 910 IIdType resourceId = theResource.getIdElement(); 911 fullId = fullyQualifyResourceIdOrReturnNull(theServer, theResource, serverBase, resourceId); 912 } 913 } 914 915 if (theAddContentLocationHeader && fullId != null) { 916 if (theRequestDetails.getRequestType() == RequestTypeEnum.POST) { 917 response.addHeader(Constants.HEADER_LOCATION, fullId.getValue()); 918 } 919 response.addHeader(Constants.HEADER_CONTENT_LOCATION, fullId.getValue()); 920 } 921 922 if (theServer.getETagSupport() == ETagSupportEnum.ENABLED) { 923 if (theRequestDetails.getRestOperationType() != null) { 924 switch (theRequestDetails.getRestOperationType()) { 925 case CREATE: 926 case UPDATE: 927 case READ: 928 case VREAD: 929 if (fullId != null && fullId.hasVersionIdPart()) { 930 String versionIdPart = fullId.getVersionIdPart(); 931 response.addHeader(Constants.HEADER_ETAG, createEtag(versionIdPart)); 932 } else if (theResource != null 933 && theResource.getMeta() != null 934 && isNotBlank(theResource.getMeta().getVersionId())) { 935 String versionId = theResource.getMeta().getVersionId(); 936 response.addHeader(Constants.HEADER_ETAG, createEtag(versionId)); 937 } 938 } 939 } 940 } 941 942 // Binary handling 943 String contentType; 944 if (theResource instanceof IBaseBinary) { 945 IBaseBinary bin = (IBaseBinary) theResource; 946 947 // Add a security context header 948 IBaseReference securityContext = BinaryUtil.getSecurityContext(theServer.getFhirContext(), bin); 949 if (securityContext != null) { 950 String securityContextRef = 951 securityContext.getReferenceElement().getValue(); 952 if (isNotBlank(securityContextRef)) { 953 response.addHeader(Constants.HEADER_X_SECURITY_CONTEXT, securityContextRef); 954 } 955 } 956 957 // If the user didn't explicitly request FHIR as a response, return binary 958 // content directly 959 if (shouldStreamContents(responseEncoding, bin)) { 960 // Force binary resources to download - This is a security measure to prevent 961 // malicious images or HTML blocks being served up as content. 962 contentType = getBinaryContentTypeOrDefault(bin); 963 response.addHeader(Constants.HEADER_CONTENT_DISPOSITION, "Attachment;"); 964 965 Integer contentLength = null; 966 if (bin.hasData()) { 967 contentLength = bin.getContent().length; 968 } 969 970 OutputStream outputStream = response.getResponseOutputStream(theStatusCode, contentType, contentLength); 971 if (bin.hasData()) { 972 outputStream.write(bin.getContent()); 973 } 974 return response.commitResponse(outputStream); 975 } 976 } 977 978 // Ok, we're not serving a binary resource, so apply default encoding 979 if (responseEncoding == null) { 980 responseEncoding = 981 new ResponseEncoding(theServer.getFhirContext(), theServer.getDefaultResponseEncoding(), null); 982 } 983 984 boolean encodingDomainResourceAsText = theSummaryMode.size() == 1 && theSummaryMode.contains(SummaryEnum.TEXT); 985 if (encodingDomainResourceAsText) { 986 /* 987 * If the user requests "text" for a bundle, only suppress the non text elements in the Element.entry.resource 988 * parts, we're not streaming just the narrative as HTML (since bundles don't even 989 * have one) 990 */ 991 if ("Bundle".equals(theServer.getFhirContext().getResourceType(theResource))) { 992 encodingDomainResourceAsText = false; 993 } 994 } 995 996 /* 997 * Last-Modified header 998 */ 999 1000 IPrimitiveType<Date> lastUpdated; 1001 if (theOperationResourceLastUpdated != null) { 1002 lastUpdated = theOperationResourceLastUpdated; 1003 } else { 1004 lastUpdated = extractLastUpdatedFromResource(theResource); 1005 } 1006 if (lastUpdated != null && lastUpdated.isEmpty() == false) { 1007 response.addHeader(Constants.HEADER_LAST_MODIFIED, DateUtils.formatDate(lastUpdated.getValue())); 1008 } 1009 1010 /* 1011 * Stream the response body 1012 */ 1013 1014 if (theResource == null) { 1015 contentType = null; 1016 } else if (encodingDomainResourceAsText) { 1017 contentType = Constants.CT_HTML; 1018 } else { 1019 contentType = responseEncoding.getResourceContentType(); 1020 } 1021 String charset = Constants.CHARSET_NAME_UTF8; 1022 1023 Writer writer = response.getResponseWriter(theStatusCode, contentType, charset, respondGzip); 1024 1025 // Interceptor call: SERVER_OUTGOING_WRITER_CREATED 1026 if (theServer.getInterceptorService() != null 1027 && theServer.getInterceptorService().hasHooks(Pointcut.SERVER_OUTGOING_WRITER_CREATED)) { 1028 HookParams params = new HookParams() 1029 .add(Writer.class, writer) 1030 .add(RequestDetails.class, theRequestDetails) 1031 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails); 1032 Object newWriter = theServer 1033 .getInterceptorService() 1034 .callHooksAndReturnObject(Pointcut.SERVER_OUTGOING_WRITER_CREATED, params); 1035 if (newWriter != null) { 1036 writer = (Writer) newWriter; 1037 } 1038 } 1039 1040 if (theResource == null) { 1041 // No response is being returned 1042 } else if (encodingDomainResourceAsText && theResource instanceof IResource) { 1043 // DSTU2 1044 writer.append(((IResource) theResource).getText().getDiv().getValueAsString()); 1045 } else if (encodingDomainResourceAsText && theResource instanceof IDomainResource) { 1046 // DSTU3+ 1047 try { 1048 writer.append(((IDomainResource) theResource).getText().getDivAsString()); 1049 } catch (Exception e) { 1050 throw new InternalErrorException(Msg.code(305) + e); 1051 } 1052 } else { 1053 FhirVersionEnum forVersion = theResource.getStructureFhirVersionEnum(); 1054 IParser parser = getNewParser(theServer.getFhirContext(), forVersion, theRequestDetails); 1055 parser.encodeResourceToWriter(theResource, writer); 1056 } 1057 1058 return response.commitResponse(writer); 1059 } 1060 1061 private static String getBinaryContentTypeOrDefault(IBaseBinary theBinary) { 1062 String contentType; 1063 if (isNotBlank(theBinary.getContentType())) { 1064 contentType = theBinary.getContentType(); 1065 } else { 1066 contentType = Constants.CT_OCTET_STREAM; 1067 } 1068 return contentType; 1069 } 1070 1071 /** 1072 * Determines whether we should stream out Binary resource content based on the content-type. Logic is: 1073 * - If the binary was externalized and has not been reinflated upstream, return false. 1074 * - If they request octet-stream, return true; 1075 * - If the content-type happens to be a match, return true. 1076 * <p> 1077 * - Construct an EncodingEnum out of the contentType. If this matches the responseEncoding, return true. 1078 * - Otherwise, return false. 1079 * 1080 * @param theResponseEncoding the requested {@link EncodingEnum} determined by the incoming Content-Type header. 1081 * @param theBinary the {@link IBaseBinary} resource to be streamed out. 1082 * @return True if response can be streamed as the requested encoding type, false otherwise. 1083 */ 1084 private static boolean shouldStreamContents(ResponseEncoding theResponseEncoding, IBaseBinary theBinary) { 1085 String contentType = theBinary.getContentType(); 1086 if (theBinary.getContent() == null) { 1087 return false; 1088 } 1089 if (theResponseEncoding == null) { 1090 return true; 1091 } 1092 if (isBlank(contentType)) { 1093 return Constants.CT_OCTET_STREAM.equals(theResponseEncoding.getContentType()); 1094 } else if (contentType.equalsIgnoreCase(theResponseEncoding.getContentType())) { 1095 return true; 1096 } else { 1097 return Objects.equals(EncodingEnum.forContentType(contentType), theResponseEncoding.getEncoding()); 1098 } 1099 } 1100 1101 public static String createEtag(String theVersionId) { 1102 return "W/\"" + theVersionId + '"'; 1103 } 1104 1105 public static Integer tryToExtractNamedParameter(RequestDetails theRequest, String theParamName) { 1106 String[] retVal = theRequest.getParameters().get(theParamName); 1107 if (retVal == null) { 1108 return null; 1109 } 1110 try { 1111 return Integer.parseInt(retVal[0]); 1112 } catch (NumberFormatException e) { 1113 ourLog.debug("Failed to parse {} value '{}': {}", theParamName, retVal[0], e.toString()); 1114 return null; 1115 } 1116 } 1117 1118 public static void validateResourceListNotNull(List<? extends IBaseResource> theResourceList) { 1119 if (theResourceList == null) { 1120 throw new InternalErrorException( 1121 Msg.code(306) + "IBundleProvider returned a null list of resources - This is not allowed"); 1122 } 1123 } 1124 1125 /** 1126 * @since 5.0.0 1127 */ 1128 public static DeleteCascadeDetails extractDeleteCascadeParameter(RequestDetails theRequest) { 1129 DeleteCascadeModeEnum mode = null; 1130 Integer maxRounds = null; 1131 if (theRequest != null) { 1132 String[] cascadeParameters = theRequest.getParameters().get(Constants.PARAMETER_CASCADE_DELETE); 1133 if (cascadeParameters != null && Arrays.asList(cascadeParameters).contains(Constants.CASCADE_DELETE)) { 1134 mode = DeleteCascadeModeEnum.DELETE; 1135 String[] maxRoundsValues = 1136 theRequest.getParameters().get(Constants.PARAMETER_CASCADE_DELETE_MAX_ROUNDS); 1137 if (maxRoundsValues != null && maxRoundsValues.length > 0) { 1138 String maxRoundsString = maxRoundsValues[0]; 1139 maxRounds = parseMaxRoundsString(maxRoundsString); 1140 } 1141 } 1142 1143 if (mode == null) { 1144 String cascadeHeader = theRequest.getHeader(Constants.HEADER_CASCADE); 1145 if (isNotBlank(cascadeHeader)) { 1146 if (Constants.CASCADE_DELETE.equals(cascadeHeader) 1147 || cascadeHeader.startsWith(Constants.CASCADE_DELETE + ";") 1148 || cascadeHeader.startsWith(Constants.CASCADE_DELETE + " ")) { 1149 mode = DeleteCascadeModeEnum.DELETE; 1150 1151 if (cascadeHeader.contains(";")) { 1152 String remainder = cascadeHeader.substring(cascadeHeader.indexOf(';') + 1); 1153 remainder = trim(remainder); 1154 if (remainder.startsWith(Constants.HEADER_CASCADE_MAX_ROUNDS + "=")) { 1155 String maxRoundsString = 1156 remainder.substring(Constants.HEADER_CASCADE_MAX_ROUNDS.length() + 1); 1157 maxRounds = parseMaxRoundsString(maxRoundsString); 1158 } 1159 } 1160 } 1161 } 1162 } 1163 } 1164 1165 if (mode == null) { 1166 mode = DeleteCascadeModeEnum.NONE; 1167 } 1168 1169 return new DeleteCascadeDetails(mode, maxRounds); 1170 } 1171 1172 @Nullable 1173 private static Integer parseMaxRoundsString(String theMaxRoundsString) { 1174 Integer maxRounds; 1175 if (isBlank(theMaxRoundsString)) { 1176 maxRounds = null; 1177 } else if (NumberUtils.isDigits(theMaxRoundsString)) { 1178 maxRounds = Integer.parseInt(theMaxRoundsString); 1179 } else { 1180 throw new InvalidRequestException(Msg.code(2349) + "Invalid value for " 1181 + Constants.PARAMETER_CASCADE_DELETE_MAX_ROUNDS + " parameter"); 1182 } 1183 return maxRounds; 1184 } 1185 1186 private enum NarrativeModeEnum { 1187 NORMAL, 1188 ONLY, 1189 SUPPRESS; 1190 1191 public static NarrativeModeEnum valueOfCaseInsensitive(String theCode) { 1192 return valueOf(NarrativeModeEnum.class, theCode.toUpperCase()); 1193 } 1194 } 1195 1196 /** 1197 * Return type for {@link RestfulServerUtils#determineRequestEncodingNoDefault(RequestDetails)} 1198 */ 1199 public static class ResponseEncoding { 1200 private final String myContentType; 1201 private final EncodingEnum myEncoding; 1202 private final Boolean myNonLegacy; 1203 1204 public ResponseEncoding(FhirContext theCtx, EncodingEnum theEncoding, String theContentType) { 1205 super(); 1206 myEncoding = theEncoding; 1207 myContentType = theContentType; 1208 if (theContentType != null) { 1209 FhirVersionEnum ctxtEnum = theCtx.getVersion().getVersion(); 1210 if (theContentType.equals(EncodingEnum.JSON_PLAIN_STRING) 1211 || theContentType.equals(EncodingEnum.XML_PLAIN_STRING)) { 1212 myNonLegacy = ctxtEnum.isNewerThan(FhirVersionEnum.DSTU2_1); 1213 } else { 1214 myNonLegacy = 1215 ctxtEnum.isNewerThan(FhirVersionEnum.DSTU2_1) && !EncodingEnum.isLegacy(theContentType); 1216 } 1217 } else { 1218 FhirVersionEnum ctxtEnum = theCtx.getVersion().getVersion(); 1219 if (ctxtEnum.isOlderThan(FhirVersionEnum.DSTU3)) { 1220 myNonLegacy = null; 1221 } else { 1222 myNonLegacy = Boolean.TRUE; 1223 } 1224 } 1225 } 1226 1227 public String getContentType() { 1228 return myContentType; 1229 } 1230 1231 public EncodingEnum getEncoding() { 1232 return myEncoding; 1233 } 1234 1235 public String getResourceContentType() { 1236 if (Boolean.TRUE.equals(isNonLegacy())) { 1237 return getEncoding().getResourceContentTypeNonLegacy(); 1238 } 1239 return getEncoding().getResourceContentType(); 1240 } 1241 1242 Boolean isNonLegacy() { 1243 return myNonLegacy; 1244 } 1245 } 1246 1247 public static class DeleteCascadeDetails { 1248 1249 private final DeleteCascadeModeEnum myMode; 1250 private final Integer myMaxRounds; 1251 1252 public DeleteCascadeDetails(DeleteCascadeModeEnum theMode, Integer theMaxRounds) { 1253 myMode = theMode; 1254 myMaxRounds = theMaxRounds; 1255 } 1256 1257 public DeleteCascadeModeEnum getMode() { 1258 return myMode; 1259 } 1260 1261 public Integer getMaxRounds() { 1262 return myMaxRounds; 1263 } 1264 } 1265}