001package ca.uhn.fhir.rest.server.interceptor;
002
003import ca.uhn.fhir.interceptor.api.Hook;
004import ca.uhn.fhir.interceptor.api.Interceptor;
005import ca.uhn.fhir.interceptor.api.Pointcut;
006import ca.uhn.fhir.rest.api.Constants;
007import ca.uhn.fhir.rest.api.EncodingEnum;
008import ca.uhn.fhir.rest.api.server.RequestDetails;
009import ca.uhn.fhir.rest.server.RestfulServer;
010import ca.uhn.fhir.rest.server.RestfulServerUtils;
011import ca.uhn.fhir.rest.server.RestfulServerUtils.ResponseEncoding;
012import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
013import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
014import ca.uhn.fhir.util.UrlUtil;
015import org.apache.commons.lang3.StringUtils;
016import org.apache.commons.lang3.Validate;
017import org.apache.commons.text.StringSubstitutor;
018import org.apache.commons.text.lookup.StringLookup;
019import org.slf4j.Logger;
020import org.slf4j.LoggerFactory;
021
022import javax.servlet.ServletException;
023import javax.servlet.http.HttpServletRequest;
024import javax.servlet.http.HttpServletResponse;
025import java.io.IOException;
026import java.util.Date;
027import java.util.Map.Entry;
028
029import static org.apache.commons.lang3.StringUtils.isNotBlank;
030
031/*
032 * #%L
033 * HAPI FHIR - Server Framework
034 * %%
035 * Copyright (C) 2014 - 2021 Smile CDR, Inc.
036 * %%
037 * Licensed under the Apache License, Version 2.0 (the "License");
038 * you may not use this file except in compliance with the License.
039 * You may obtain a copy of the License at
040 *
041 *      http://www.apache.org/licenses/LICENSE-2.0
042 *
043 * Unless required by applicable law or agreed to in writing, software
044 * distributed under the License is distributed on an "AS IS" BASIS,
045 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
046 * See the License for the specific language governing permissions and
047 * limitations under the License.
048 * #L%
049 */
050
051/**
052 * Server interceptor which logs each request using a defined format
053 * <p>
054 * The following substitution variables are supported:
055 * </p>
056 * <table summary="Substitution variables supported by this class">
057 * <tr>
058 * <td>${id}</td>
059 * <td>The resource ID associated with this request (or "" if none)</td>
060 * </tr>
061 * <tr>
062 * <td>${idOrResourceName}</td>
063 * <td>The resource ID associated with this request, or the resource name if the request applies to a type but not an
064 * instance, or "" otherwise</td>
065 * </tr>
066 * <tr>
067 * <td>${operationName}</td>
068 * <td>If the request is an extended operation (e.g. "$validate") this value will be the operation name, or ""
069 * otherwise</td>
070 * </tr>
071 * <tr>
072 * <td>${requestId}</td>
073 * <td>The request ID assigned to this request (either automatically, or via the <code>X-Request-ID</code> header in the request)</td>
074 * </tr>
075 * <tr>
076 * <td>${operationType}</td>
077 * <td>A code indicating the operation type for this request, e.g. "read", "history-instance",
078 * "extended-operation-instance", etc.)</td>
079 * </tr>
080 * <tr>
081 * <td>${remoteAddr}</td>
082 * <td>The originating IP of the request</td>
083 * </tr>
084 * <tr>
085 * <td>${requestHeader.XXXX}</td>
086 * <td>The value of the HTTP request header named XXXX. For example, a substitution variable named
087 * "${requestHeader.x-forwarded-for} will yield the value of the first header named "x-forwarded-for
088 * ", or "" if none.</td>
089 * </tr>
090 * <tr>
091 * <td>${requestParameters}</td>
092 * <td>The HTTP request parameters (or "")</td>
093 * </tr>
094 * <tr>
095 * <td>${responseEncodingNoDefault}</td>
096 * <td>The encoding format requested by the client via the _format parameter or the Accept header. Value will be "json"
097 * or "xml", or "" if the client did not explicitly request a format</td>
098 * </tr>
099 * <tr>
100 * <td>${servletPath}</td>
101 * <td>The part of thre requesting URL that corresponds to the particular Servlet being called (see
102 * {@link HttpServletRequest#getServletPath()})</td>
103 * </tr>
104 * <tr>
105 * <td>${requestBodyFhir}</td>
106 * <td>The complete body of the request if the request has a FHIR content-type (this can be quite large!). Will emit an
107 * empty string if the content type is not a FHIR content type</td>
108 * </tr>
109 * <tr>
110 * <td>${requestUrl}</td>
111 * <td>The complete URL of the request</td>
112 * </tr>
113 * <tr>
114 * <td>${requestVerb}</td>
115 * <td>The HTTP verb of the request</td>
116 * </tr>
117 * <tr>
118 * <td>${exceptionMessage}</td>
119 * <td>Applies only to an error message: The message from {@link Exception#getMessage()}</td>
120 * </tr>
121 * <tr>
122 * <td>${processingTimeMillis}</td>
123 * <td>The number of milliseconds spen processing this request</td>
124 * </tr>
125 * </table>
126 */
127@Interceptor
128public class LoggingInterceptor {
129
130        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(LoggingInterceptor.class);
131
132        private String myErrorMessageFormat = "ERROR - ${operationType} - ${idOrResourceName}";
133        private boolean myLogExceptions = true;
134        private Logger myLogger = ourLog;
135        private String myMessageFormat = "${operationType} - ${idOrResourceName}";
136
137        /**
138         * Constructor for server logging interceptor
139         */
140        public LoggingInterceptor() {
141                super();
142        }
143        
144        /**
145         * Get the log message format to be used when logging exceptions
146         */
147        public String getErrorMessageFormat() {
148                return myErrorMessageFormat;
149        }
150
151        @Hook(Pointcut.SERVER_HANDLE_EXCEPTION)
152        public boolean handleException(RequestDetails theRequestDetails, BaseServerResponseException theException, HttpServletRequest theServletRequest, HttpServletResponse theServletResponse) throws ServletException, IOException {
153                if (myLogExceptions) {
154                        // Perform any string substitutions from the message format
155                        StringLookup lookup = new MyLookup(theServletRequest, theException, theRequestDetails);
156                        StringSubstitutor subs = new StringSubstitutor(lookup, "${", "}", '\\');
157
158                        // Actuall log the line
159                        String line = subs.replace(myErrorMessageFormat);
160                        myLogger.info(line);
161
162                }
163                return true;
164        }
165
166
167        @Hook(Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY)
168        public void processingCompletedNormally(ServletRequestDetails theRequestDetails) {
169                // Perform any string substitutions from the message format
170                StringLookup lookup = new MyLookup(theRequestDetails.getServletRequest(), theRequestDetails);
171                StringSubstitutor subs = new StringSubstitutor(lookup, "${", "}", '\\');
172
173                // Actually log the line
174                String line = subs.replace(myMessageFormat);
175                myLogger.info(line);
176        }
177
178        /**
179         * Should exceptions be logged by this logger
180         */
181        public boolean isLogExceptions() {
182                return myLogExceptions;
183        }
184
185        /**
186         * Set the log message format to be used when logging exceptions
187         */
188        public void setErrorMessageFormat(String theErrorMessageFormat) {
189                Validate.notBlank(theErrorMessageFormat, "Message format can not be null/empty");
190                myErrorMessageFormat = theErrorMessageFormat;
191        }
192
193        /**
194         * Should exceptions be logged by this logger
195         */
196        public void setLogExceptions(boolean theLogExceptions) {
197                myLogExceptions = theLogExceptions;
198        }
199
200        public void setLogger(Logger theLogger) {
201                Validate.notNull(theLogger, "Logger can not be null");
202                myLogger = theLogger;
203        }
204
205        public void setLoggerName(String theLoggerName) {
206                Validate.notBlank(theLoggerName, "Logger name can not be null/empty");
207                myLogger = LoggerFactory.getLogger(theLoggerName);
208
209        }
210
211        /**
212         * Sets the message format itself. See the {@link LoggingInterceptor class documentation} for information on the
213         * format
214         */
215        public void setMessageFormat(String theMessageFormat) {
216                Validate.notBlank(theMessageFormat, "Message format can not be null/empty");
217                myMessageFormat = theMessageFormat;
218        }
219
220        private static final class MyLookup implements StringLookup {
221                private final Throwable myException;
222                private final HttpServletRequest myRequest;
223                private final RequestDetails myRequestDetails;
224
225                private MyLookup(HttpServletRequest theRequest, RequestDetails theRequestDetails) {
226                        myRequest = theRequest;
227                        myRequestDetails = theRequestDetails;
228                        myException = null;
229                }
230
231                MyLookup(HttpServletRequest theServletRequest, BaseServerResponseException theException, RequestDetails theRequestDetails) {
232                        myException = theException;
233                        myRequestDetails = theRequestDetails;
234                        myRequest = theServletRequest;
235                }
236
237                @Override
238                public String lookup(String theKey) {
239
240                        /*
241                         * TODO: this method could be made more efficient through some sort of lookup map
242                         */
243
244                        if ("operationType".equals(theKey)) {
245                                if (myRequestDetails.getRestOperationType() != null) {
246                                        return myRequestDetails.getRestOperationType().getCode();
247                                }
248                                return "";
249                        } else if ("operationName".equals(theKey)) {
250                                if (myRequestDetails.getRestOperationType() != null) {
251                                        switch (myRequestDetails.getRestOperationType()) {
252                                        case EXTENDED_OPERATION_INSTANCE:
253                                        case EXTENDED_OPERATION_SERVER:
254                                        case EXTENDED_OPERATION_TYPE:
255                                                return myRequestDetails.getOperation();
256                                        default:
257                                                return "";
258                                        }
259                                }
260                                        return "";
261                        } else if ("id".equals(theKey)) {
262                                if (myRequestDetails.getId() != null) {
263                                        return myRequestDetails.getId().getValue();
264                                }
265                                return "";
266                        } else if ("servletPath".equals(theKey)) {
267                                return StringUtils.defaultString(myRequest.getServletPath());
268                        } else if ("idOrResourceName".equals(theKey)) {
269                                if (myRequestDetails.getId() != null) {
270                                        return myRequestDetails.getId().getValue();
271                                }
272                                if (myRequestDetails.getResourceName() != null) {
273                                        return myRequestDetails.getResourceName();
274                                }
275                                return "";
276                        } else if (theKey.equals("requestParameters")) {
277                                StringBuilder b = new StringBuilder();
278                                for (Entry<String, String[]> next : myRequestDetails.getParameters().entrySet()) {
279                                        for (String nextValue : next.getValue()) {
280                                                if (b.length() == 0) {
281                                                        b.append('?');
282                                                } else {
283                                                        b.append('&');
284                                                }
285                                                b.append(UrlUtil.escapeUrlParam(next.getKey()));
286                                                b.append('=');
287                                                b.append(UrlUtil.escapeUrlParam(nextValue));
288                                        }
289                                }
290                                return b.toString();
291                        } else if (theKey.startsWith("requestHeader.")) {
292                                String val = myRequest.getHeader(theKey.substring("requestHeader.".length()));
293                                return StringUtils.defaultString(val);
294                        } else if (theKey.startsWith("remoteAddr")) {
295                                return StringUtils.defaultString(myRequest.getRemoteAddr());
296                        } else if (theKey.equals("responseEncodingNoDefault")) {
297                                ResponseEncoding encoding = RestfulServerUtils.determineResponseEncodingNoDefault(myRequestDetails, myRequestDetails.getServer().getDefaultResponseEncoding());
298                                if (encoding != null) {
299                                        return encoding.getEncoding().name();
300                                }
301                                return "";
302                        } else if (theKey.equals("exceptionMessage")) {
303                                return myException != null ? myException.getMessage() : null;
304                        } else if (theKey.equals("requestUrl")) {
305                                return myRequest.getRequestURL().toString();
306                        } else if (theKey.equals("requestVerb")) {
307                                return myRequest.getMethod();
308                        } else if (theKey.equals("requestBodyFhir")) {
309                                String contentType = myRequest.getContentType();
310                                if (isNotBlank(contentType)) {
311                                        int colonIndex = contentType.indexOf(';');
312                                        if (colonIndex != -1) {
313                                                contentType = contentType.substring(0, colonIndex);
314                                        }
315                                        contentType = contentType.trim();
316
317                                        EncodingEnum encoding = EncodingEnum.forContentType(contentType);
318                                        if (encoding != null) {
319                                                byte[] requestContents = myRequestDetails.loadRequestContents();
320                                                return new String(requestContents, Constants.CHARSET_UTF8);
321                                        }
322                                }
323                                return "";
324                        } else if ("processingTimeMillis".equals(theKey)) {
325                                Date startTime = (Date) myRequest.getAttribute(RestfulServer.REQUEST_START_TIME);
326                                if (startTime != null) {
327                                        long time = System.currentTimeMillis() - startTime.getTime();
328                                        return Long.toString(time);
329                                }
330                        } else if ("requestId".equals(theKey)) {
331                                return myRequestDetails.getRequestId();
332                        }
333
334                        return "!VAL!";
335                }
336        }
337
338}