001package ca.uhn.fhir.jpa.subscription.submit.interceptor;
002
003/*-
004 * #%L
005 * HAPI FHIR Subscription Server
006 * %%
007 * Copyright (C) 2014 - 2021 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.interceptor.api.Hook;
025import ca.uhn.fhir.interceptor.api.Interceptor;
026import ca.uhn.fhir.interceptor.api.Pointcut;
027import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
028import ca.uhn.fhir.jpa.model.util.JpaConstants;
029import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionMatchingStrategy;
030import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionStrategyEvaluator;
031import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionCanonicalizer;
032import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription;
033import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscriptionChannelType;
034import ca.uhn.fhir.parser.DataFormatException;
035import ca.uhn.fhir.rest.api.EncodingEnum;
036import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
037import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
038import ca.uhn.fhir.util.HapiExtensions;
039import com.google.common.annotations.VisibleForTesting;
040import org.hl7.fhir.instance.model.api.IBaseResource;
041import org.springframework.beans.factory.annotation.Autowired;
042
043import java.net.URI;
044import java.net.URISyntaxException;
045
046import static org.apache.commons.lang3.StringUtils.isBlank;
047
048@Interceptor
049public class SubscriptionValidatingInterceptor {
050
051        @Autowired
052        private SubscriptionCanonicalizer mySubscriptionCanonicalizer;
053        @Autowired
054        private DaoRegistry myDaoRegistry;
055        @Autowired
056        private SubscriptionStrategyEvaluator mySubscriptionStrategyEvaluator;
057        @Autowired
058        private FhirContext myFhirContext;
059
060        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
061        public void resourcePreCreate(IBaseResource theResource) {
062                validateSubmittedSubscription(theResource);
063        }
064
065        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
066        public void resourcePreCreate(IBaseResource theOldResource, IBaseResource theResource) {
067                validateSubmittedSubscription(theResource);
068        }
069
070        @VisibleForTesting
071        public void setFhirContextForUnitTest(FhirContext theFhirContext) {
072                myFhirContext = theFhirContext;
073        }
074
075        public void validateSubmittedSubscription(IBaseResource theSubscription) {
076                if (!"Subscription".equals(myFhirContext.getResourceType(theSubscription))) {
077                        return;
078                }
079
080                CanonicalSubscription subscription = mySubscriptionCanonicalizer.canonicalize(theSubscription);
081                boolean finished = false;
082                if (subscription.getStatus() == null) {
083                        throw new UnprocessableEntityException("Can not process submitted Subscription - Subscription.status must be populated on this server");
084                }
085
086                switch (subscription.getStatus()) {
087                        case REQUESTED:
088                        case ACTIVE:
089                                break;
090                        case ERROR:
091                        case OFF:
092                        case NULL:
093                                finished = true;
094                                break;
095                }
096
097                mySubscriptionCanonicalizer.setMatchingStrategyTag(theSubscription, null);
098
099                if (!finished) {
100
101                        validateQuery(subscription.getCriteriaString(), "Subscription.criteria");
102
103                        if (subscription.getPayloadSearchCriteria() != null) {
104                                validateQuery(subscription.getPayloadSearchCriteria(), "Subscription.extension(url='" + HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA + "')");
105                        }
106
107                        validateChannelType(subscription);
108
109                        try {
110                                SubscriptionMatchingStrategy strategy = mySubscriptionStrategyEvaluator.determineStrategy(subscription.getCriteriaString());
111                                mySubscriptionCanonicalizer.setMatchingStrategyTag(theSubscription, strategy);
112                        } catch (InvalidRequestException | DataFormatException e) {
113                                throw new UnprocessableEntityException("Invalid subscription criteria submitted: " + subscription.getCriteriaString() + " " + e.getMessage());
114                        }
115
116                        if (subscription.getChannelType() == null) {
117                                throw new UnprocessableEntityException("Subscription.channel.type must be populated on this server");
118                        } else if (subscription.getChannelType() == CanonicalSubscriptionChannelType.MESSAGE) {
119                                validateMessageSubscriptionEndpoint(subscription.getEndpointUrl());
120                        }
121
122
123                }
124        }
125
126        public void validateQuery(String theQuery, String theFieldName) {
127                if (isBlank(theQuery)) {
128                        throw new UnprocessableEntityException(theFieldName + " must be populated");
129                }
130
131                int sep = theQuery.indexOf('?');
132                if (sep <= 1) {
133                        throw new UnprocessableEntityException(theFieldName + " must be in the form \"{Resource Type}?[params]\"");
134                }
135
136                String resType = theQuery.substring(0, sep);
137                if (resType.contains("/")) {
138                        throw new UnprocessableEntityException(theFieldName + " must be in the form \"{Resource Type}?[params]\"");
139                }
140
141                if (!myDaoRegistry.isResourceTypeSupported(resType)) {
142                        throw new UnprocessableEntityException(theFieldName + " contains invalid/unsupported resource type: " + resType);
143                }
144        }
145
146        public void validateMessageSubscriptionEndpoint(String theEndpointUrl) {
147                if (theEndpointUrl == null) {
148                        throw new UnprocessableEntityException("No endpoint defined for message subscription");
149                }
150
151                try {
152                        URI uri = new URI(theEndpointUrl);
153
154                        if (!"channel".equals(uri.getScheme())) {
155                                throw new UnprocessableEntityException("Only 'channel' protocol is supported for Subscriptions with channel type 'message'");
156                        }
157                        String channelName = uri.getSchemeSpecificPart();
158                        if (isBlank(channelName)) {
159                                throw new UnprocessableEntityException("A channel name must appear after channel: in a message Subscription endpoint");
160                        }
161                } catch (URISyntaxException e) {
162                        throw new UnprocessableEntityException("Invalid subscription endpoint uri " + theEndpointUrl, e);
163                }
164        }
165
166        @SuppressWarnings("WeakerAccess")
167        protected void validateChannelType(CanonicalSubscription theSubscription) {
168                if (theSubscription.getChannelType() == null) {
169                        throw new UnprocessableEntityException("Subscription.channel.type must be populated");
170                } else if (theSubscription.getChannelType() == CanonicalSubscriptionChannelType.RESTHOOK) {
171                        validateChannelPayload(theSubscription);
172                        validateChannelEndpoint(theSubscription);
173                }
174        }
175
176        @SuppressWarnings("WeakerAccess")
177        protected void validateChannelEndpoint(CanonicalSubscription theResource) {
178                if (isBlank(theResource.getEndpointUrl())) {
179                        throw new UnprocessableEntityException("Rest-hook subscriptions must have Subscription.channel.endpoint defined");
180                }
181        }
182
183        @SuppressWarnings("WeakerAccess")
184        protected void validateChannelPayload(CanonicalSubscription theResource) {
185                if (!isBlank(theResource.getPayloadString()) && EncodingEnum.forContentType(theResource.getPayloadString()) == null) {
186                        throw new UnprocessableEntityException("Invalid value for Subscription.channel.payload: " + theResource.getPayloadString());
187                }
188        }
189
190        @SuppressWarnings("WeakerAccess")
191        @VisibleForTesting
192        public void setSubscriptionCanonicalizerForUnitTest(SubscriptionCanonicalizer theSubscriptionCanonicalizer) {
193                mySubscriptionCanonicalizer = theSubscriptionCanonicalizer;
194        }
195
196        @SuppressWarnings("WeakerAccess")
197        @VisibleForTesting
198        public void setDaoRegistryForUnitTest(DaoRegistry theDaoRegistry) {
199                myDaoRegistry = theDaoRegistry;
200        }
201
202
203        @VisibleForTesting
204        @SuppressWarnings("WeakerAccess")
205        public void setSubscriptionStrategyEvaluatorForUnitTest(SubscriptionStrategyEvaluator theSubscriptionStrategyEvaluator) {
206                mySubscriptionStrategyEvaluator = theSubscriptionStrategyEvaluator;
207        }
208
209}