001package ca.uhn.fhir.jpa.subscription.submit.interceptor; 002 003/*- 004 * #%L 005 * HAPI FHIR Subscription Server 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.Hook; 026import ca.uhn.fhir.interceptor.api.Interceptor; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.interceptor.model.RequestPartitionId; 029import ca.uhn.fhir.jpa.api.config.DaoConfig; 030import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 031import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; 032import ca.uhn.fhir.jpa.partition.SystemRequestDetails; 033import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionMatchingStrategy; 034import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionStrategyEvaluator; 035import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionCriteriaParser; 036import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionCanonicalizer; 037import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; 038import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscriptionChannelType; 039import ca.uhn.fhir.parser.DataFormatException; 040import ca.uhn.fhir.rest.api.EncodingEnum; 041import ca.uhn.fhir.rest.api.server.RequestDetails; 042import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 043import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 044import ca.uhn.fhir.util.HapiExtensions; 045import ca.uhn.fhir.util.SubscriptionUtil; 046import com.google.common.annotations.VisibleForTesting; 047import org.hl7.fhir.instance.model.api.IBaseResource; 048import org.springframework.beans.factory.annotation.Autowired; 049 050import java.net.URI; 051import java.net.URISyntaxException; 052import java.util.Objects; 053 054import static org.apache.commons.lang3.StringUtils.isBlank; 055@Interceptor 056public class SubscriptionValidatingInterceptor { 057 058 @Autowired 059 private SubscriptionCanonicalizer mySubscriptionCanonicalizer; 060 @Autowired 061 private DaoRegistry myDaoRegistry; 062 @Autowired 063 private DaoConfig myDaoConfig; 064 @Autowired 065 private SubscriptionStrategyEvaluator mySubscriptionStrategyEvaluator; 066 @Autowired 067 private FhirContext myFhirContext; 068 @Autowired 069 private IRequestPartitionHelperSvc myRequestPartitionHelperSvc; 070 071 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) 072 public void resourcePreCreate(IBaseResource theResource, RequestDetails theRequestDetails) { 073 validateSubmittedSubscription(theResource, theRequestDetails); 074 } 075 076 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) 077 public void resourcePreCreate(IBaseResource theOldResource, IBaseResource theResource, RequestDetails theRequestDetails) { 078 validateSubmittedSubscription(theResource, theRequestDetails); 079 } 080 081 @VisibleForTesting 082 public void setFhirContextForUnitTest(FhirContext theFhirContext) { 083 myFhirContext = theFhirContext; 084 } 085 086 @Deprecated 087 public void validateSubmittedSubscription(IBaseResource theSubscription) { 088 validateSubmittedSubscription(theSubscription, null); 089 } 090 091 public void validateSubmittedSubscription(IBaseResource theSubscription, RequestDetails theRequestDetails) { 092 if (!"Subscription".equals(myFhirContext.getResourceType(theSubscription))) { 093 return; 094 } 095 096 CanonicalSubscription subscription = mySubscriptionCanonicalizer.canonicalize(theSubscription); 097 boolean finished = false; 098 if (subscription.getStatus() == null) { 099 throw new UnprocessableEntityException(Msg.code(8) + "Can not process submitted Subscription - Subscription.status must be populated on this server"); 100 } 101 102 switch (subscription.getStatus()) { 103 case REQUESTED: 104 case ACTIVE: 105 break; 106 case ERROR: 107 case OFF: 108 case NULL: 109 finished = true; 110 break; 111 } 112 113 // If the subscription has the cross partition tag && 114 if (SubscriptionUtil.isCrossPartition(theSubscription) && !(theRequestDetails instanceof SystemRequestDetails)) { 115 if (!myDaoConfig.isCrossPartitionSubscription()){ 116 throw new UnprocessableEntityException(Msg.code(2009) + "Cross partition subscription is not enabled on this server"); 117 } 118 119 if (!determinePartition(theRequestDetails, theSubscription).isDefaultPartition()) { 120 throw new UnprocessableEntityException(Msg.code(2010) + "Cross partition subscription must be created on the default partition"); 121 } 122 } 123 124 mySubscriptionCanonicalizer.setMatchingStrategyTag(theSubscription, null); 125 126 if (!finished) { 127 128 validateQuery(subscription.getCriteriaString(), "Subscription.criteria"); 129 130 if (subscription.getPayloadSearchCriteria() != null) { 131 validateQuery(subscription.getPayloadSearchCriteria(), "Subscription.extension(url='" + HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA + "')"); 132 } 133 134 validateChannelType(subscription); 135 136 try { 137 SubscriptionMatchingStrategy strategy = mySubscriptionStrategyEvaluator.determineStrategy(subscription.getCriteriaString()); 138 mySubscriptionCanonicalizer.setMatchingStrategyTag(theSubscription, strategy); 139 } catch (InvalidRequestException | DataFormatException e) { 140 throw new UnprocessableEntityException(Msg.code(9) + "Invalid subscription criteria submitted: " + subscription.getCriteriaString() + " " + e.getMessage()); 141 } 142 143 if (subscription.getChannelType() == null) { 144 throw new UnprocessableEntityException(Msg.code(10) + "Subscription.channel.type must be populated on this server"); 145 } else if (subscription.getChannelType() == CanonicalSubscriptionChannelType.MESSAGE) { 146 validateMessageSubscriptionEndpoint(subscription.getEndpointUrl()); 147 } 148 149 150 } 151 } 152 153 private RequestPartitionId determinePartition(RequestDetails theRequestDetails, IBaseResource theResource) { 154 switch (theRequestDetails.getRestOperationType()) { 155 case CREATE: 156 return myRequestPartitionHelperSvc.determineCreatePartitionForRequest(theRequestDetails, theResource, "Subscription"); 157 case UPDATE: 158 return myRequestPartitionHelperSvc.determineReadPartitionForRequestForRead(theRequestDetails, "Subscription", theResource.getIdElement()); 159 default: 160 return null; 161 } 162 } 163 164 public void validateQuery(String theQuery, String theFieldName) { 165 if (isBlank(theQuery)) { 166 throw new UnprocessableEntityException(Msg.code(11) + theFieldName + " must be populated"); 167 } 168 169 SubscriptionCriteriaParser.SubscriptionCriteria parsedCriteria = SubscriptionCriteriaParser.parse(theQuery); 170 if (parsedCriteria == null) { 171 throw new UnprocessableEntityException(Msg.code(12) + theFieldName + " can not be parsed"); 172 } 173 174 if (parsedCriteria.getType() == SubscriptionCriteriaParser.TypeEnum.STARTYPE_EXPRESSION) { 175 return; 176 } 177 178 for (String next : parsedCriteria.getApplicableResourceTypes()) { 179 if (!myDaoRegistry.isResourceTypeSupported(next)) { 180 throw new UnprocessableEntityException(Msg.code(13) + theFieldName + " contains invalid/unsupported resource type: " + next); 181 } 182 } 183 184 if (parsedCriteria.getType() != SubscriptionCriteriaParser.TypeEnum.SEARCH_EXPRESSION) { 185 return; 186 } 187 188 int sep = theQuery.indexOf('?'); 189 if (sep <= 1) { 190 throw new UnprocessableEntityException(Msg.code(14) + theFieldName + " must be in the form \"{Resource Type}?[params]\""); 191 } 192 193 String resType = theQuery.substring(0, sep); 194 if (resType.contains("/")) { 195 throw new UnprocessableEntityException(Msg.code(15) + theFieldName + " must be in the form \"{Resource Type}?[params]\""); 196 } 197 198 } 199 200 public void validateMessageSubscriptionEndpoint(String theEndpointUrl) { 201 if (theEndpointUrl == null) { 202 throw new UnprocessableEntityException(Msg.code(16) + "No endpoint defined for message subscription"); 203 } 204 205 try { 206 URI uri = new URI(theEndpointUrl); 207 208 if (!"channel".equals(uri.getScheme())) { 209 throw new UnprocessableEntityException(Msg.code(17) + "Only 'channel' protocol is supported for Subscriptions with channel type 'message'"); 210 } 211 String channelName = uri.getSchemeSpecificPart(); 212 if (isBlank(channelName)) { 213 throw new UnprocessableEntityException(Msg.code(18) + "A channel name must appear after channel: in a message Subscription endpoint"); 214 } 215 } catch (URISyntaxException e) { 216 throw new UnprocessableEntityException(Msg.code(19) + "Invalid subscription endpoint uri " + theEndpointUrl, e); 217 } 218 } 219 220 @SuppressWarnings("WeakerAccess") 221 protected void validateChannelType(CanonicalSubscription theSubscription) { 222 if (theSubscription.getChannelType() == null) { 223 throw new UnprocessableEntityException(Msg.code(20) + "Subscription.channel.type must be populated"); 224 } else if (theSubscription.getChannelType() == CanonicalSubscriptionChannelType.RESTHOOK) { 225 validateChannelPayload(theSubscription); 226 validateChannelEndpoint(theSubscription); 227 } 228 } 229 230 @SuppressWarnings("WeakerAccess") 231 protected void validateChannelEndpoint(CanonicalSubscription theResource) { 232 if (isBlank(theResource.getEndpointUrl())) { 233 throw new UnprocessableEntityException(Msg.code(21) + "Rest-hook subscriptions must have Subscription.channel.endpoint defined"); 234 } 235 } 236 237 @SuppressWarnings("WeakerAccess") 238 protected void validateChannelPayload(CanonicalSubscription theResource) { 239 if (!isBlank(theResource.getPayloadString()) && EncodingEnum.forContentType(theResource.getPayloadString()) == null) { 240 throw new UnprocessableEntityException(Msg.code(1985) + "Invalid value for Subscription.channel.payload: " + theResource.getPayloadString()); 241 } 242 } 243 244 @SuppressWarnings("WeakerAccess") 245 @VisibleForTesting 246 public void setSubscriptionCanonicalizerForUnitTest(SubscriptionCanonicalizer theSubscriptionCanonicalizer) { 247 mySubscriptionCanonicalizer = theSubscriptionCanonicalizer; 248 } 249 250 @SuppressWarnings("WeakerAccess") 251 @VisibleForTesting 252 public void setDaoRegistryForUnitTest(DaoRegistry theDaoRegistry) { 253 myDaoRegistry = theDaoRegistry; 254 } 255 256 @VisibleForTesting 257 public void setDaoConfigForUnitTest(DaoConfig theDaoConfig) { 258 myDaoConfig = theDaoConfig; 259 } 260 261 @VisibleForTesting 262 public void setRequestPartitionHelperSvcForUnitTest(IRequestPartitionHelperSvc theRequestPartitionHelperSvc) { 263 myRequestPartitionHelperSvc = theRequestPartitionHelperSvc; 264 } 265 266 267 @VisibleForTesting 268 @SuppressWarnings("WeakerAccess") 269 public void setSubscriptionStrategyEvaluatorForUnitTest(SubscriptionStrategyEvaluator theSubscriptionStrategyEvaluator) { 270 mySubscriptionStrategyEvaluator = theSubscriptionStrategyEvaluator; 271 } 272 273}