001    /**
002     * Licensed to the Apache Software Foundation (ASF) under one
003     * or more contributor license agreements.  See the NOTICE file
004     * distributed with this work for additional information
005     * regarding copyright ownership.  The ASF licenses this file
006     * to you under the Apache License, Version 2.0 (the
007     * "License"); you may not use this file except in compliance
008     * with the License.  You may obtain a copy of the License at
009     * 
010     *      http://www.apache.org/licenses/LICENSE-2.0
011     * 
012     * Unless required by applicable law or agreed to in writing, software
013     * distributed under the License is distributed on an "AS IS" BASIS,
014     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015     * See the License for the specific language governing permissions and
016     * limitations under the License.
017     */
018    package org.apache.oozie.servlet;
019    
020    import org.apache.oozie.client.OozieClient.SYSTEM_MODE;
021    import org.apache.oozie.client.rest.JsonBean;
022    import org.apache.oozie.client.rest.RestConstants;
023    import org.apache.oozie.service.DagXLogInfoService;
024    import org.apache.oozie.service.InstrumentationService;
025    import org.apache.oozie.service.ProxyUserService;
026    import org.apache.oozie.service.Services;
027    import org.apache.oozie.service.XLogService;
028    import org.apache.oozie.util.Instrumentation;
029    import org.apache.oozie.util.ParamChecker;
030    import org.apache.oozie.util.XLog;
031    import org.apache.oozie.ErrorCode;
032    import org.json.simple.JSONObject;
033    import org.json.simple.JSONStreamAware;
034    
035    import javax.servlet.ServletConfig;
036    import javax.servlet.ServletException;
037    import javax.servlet.http.HttpServlet;
038    import javax.servlet.http.HttpServletRequest;
039    import javax.servlet.http.HttpServletResponse;
040    import java.io.IOException;
041    import java.security.AccessControlException;
042    import java.util.*;
043    import java.util.concurrent.atomic.AtomicLong;
044    
045    /**
046     * Base class for Oozie web service API Servlets. <p/> This class provides common instrumentation, error logging and
047     * other common functionality.
048     */
049    public abstract class JsonRestServlet extends HttpServlet {
050    
051        private static final String JSTON_UTF8 = RestConstants.JSON_CONTENT_TYPE + "; charset=\"UTF-8\"";
052    
053        protected static final String XML_UTF8 = RestConstants.XML_CONTENT_TYPE + "; charset=\"UTF-8\"";
054    
055        protected static final String TEXT_UTF8 = RestConstants.TEXT_CONTENT_TYPE + "; charset=\"UTF-8\"";
056    
057        protected static final String AUDIT_OPERATION = "audit.operation";
058        protected static final String AUDIT_PARAM = "audit.param";
059        protected static final String AUDIT_ERROR_CODE = "audit.error.code";
060        protected static final String AUDIT_ERROR_MESSAGE = "audit.error.message";
061        protected static final String AUDIT_HTTP_STATUS_CODE = "audit.http.status.code";
062    
063        private XLog auditLog;
064        XLog.Info logInfo;
065    
066        /**
067         * This bean defines a query string parameter.
068         */
069        public static class ParameterInfo {
070            private String name;
071            private Class type;
072            private List<String> methods;
073            private boolean required;
074    
075            /**
076             * Creates a ParameterInfo with querystring parameter definition.
077             *
078             * @param name querystring parameter name.
079             * @param type type for the parameter value, valid types are: <code>Integer, Boolean and String</code>
080             * @param required indicates if the parameter is required.
081             * @param methods HTTP methods the parameter is used by.
082             */
083            public ParameterInfo(String name, Class type, boolean required, List<String> methods) {
084                this.name = ParamChecker.notEmpty(name, "name");
085                if (type != Integer.class && type != Boolean.class && type != String.class) {
086                    throw new IllegalArgumentException("Type must be integer, boolean or string");
087                }
088                this.type = ParamChecker.notNull(type, "type");
089                this.required = required;
090                this.methods = ParamChecker.notNull(methods, "methods");
091            }
092    
093        }
094    
095        /**
096         * This bean defines a REST resource.
097         */
098        public static class ResourceInfo {
099            private String name;
100            private boolean wildcard;
101            private List<String> methods;
102            private Map<String, ParameterInfo> parameters = new HashMap<String, ParameterInfo>();
103    
104            /**
105             * Creates a ResourceInfo with a REST resource definition.
106             *
107             * @param name name of the REST resource, it can be an fixed resource name, empty or a wildcard ('*').
108             * @param methods HTTP methods supported by the resource.
109             * @param parameters parameters supported by the resource.
110             */
111            public ResourceInfo(String name, List<String> methods, List<ParameterInfo> parameters) {
112                this.name = name;
113                wildcard = name.equals("*");
114                for (ParameterInfo parameter : parameters) {
115                    this.parameters.put(parameter.name, parameter);
116                }
117                this.methods = ParamChecker.notNull(methods, "methods");
118            }
119        }
120    
121        /**
122         * Name of the instrumentation group for the WS layer, value is 'webservices'.
123         */
124        protected static final String INSTRUMENTATION_GROUP = "webservices";
125    
126        private static final String INSTR_TOTAL_REQUESTS_SAMPLER = "requests";
127        private static final String INSTR_TOTAL_REQUESTS_COUNTER = "requests";
128        private static final String INSTR_TOTAL_FAILED_REQUESTS_COUNTER = "failed";
129        private static AtomicLong TOTAL_REQUESTS_SAMPLER_COUNTER;
130    
131        private Instrumentation instrumentation;
132        private String instrumentationName;
133        private AtomicLong samplerCounter = new AtomicLong();
134        private ThreadLocal<Instrumentation.Cron> requestCron = new ThreadLocal<Instrumentation.Cron>();
135        private List<ResourceInfo> resourcesInfo = new ArrayList<ResourceInfo>();
136        private boolean allowSafeModeChanges;
137    
138        /**
139         * Creates a servlet with a specified instrumentation sampler name for its requests.
140         *
141         * @param instrumentationName instrumentation name for timer and samplers for the servlet.
142         * @param resourcesInfo list of resource definitions supported by the servlet, empty and wildcard resources must be
143         * the last ones, in that order, first empty and the wildcard.
144         */
145        public JsonRestServlet(String instrumentationName, ResourceInfo... resourcesInfo) {
146            this.instrumentationName = ParamChecker.notEmpty(instrumentationName, "instrumentationName");
147            if (resourcesInfo.length == 0) {
148                throw new IllegalArgumentException("There must be at least one ResourceInfo");
149            }
150            this.resourcesInfo = Arrays.asList(resourcesInfo);
151            auditLog = XLog.getLog("oozieaudit");
152            auditLog.setMsgPrefix("");
153            logInfo = new XLog.Info(XLog.Info.get());
154        }
155    
156        /**
157         * Enable HTTP POST/PUT/DELETE methods while in safe mode.
158         *
159         * @param allow <code>true</code> enabled safe mode changes, <code>false</code> disable safe mode changes
160         * (default).
161         */
162        protected void setAllowSafeModeChanges(boolean allow) {
163            allowSafeModeChanges = allow;
164        }
165    
166        /**
167         * Define an instrumentation sampler. <p/> Sampling period is 60 seconds, the sampling frequency is 1 second. <p/>
168         * The instrumentation group used is {@link #INSTRUMENTATION_GROUP}.
169         *
170         * @param samplerName sampler name.
171         * @param samplerCounter sampler counter.
172         */
173        private void defineSampler(String samplerName, final AtomicLong samplerCounter) {
174            instrumentation.addSampler(INSTRUMENTATION_GROUP, samplerName, 60, 1, new Instrumentation.Variable<Long>() {
175                public Long getValue() {
176                    return samplerCounter.get();
177                }
178            });
179        }
180    
181        /**
182         * Add an instrumentation cron.
183         *
184         * @param name name of the timer for the cron.
185         * @param cron cron to add to a instrumentation timer.
186         */
187        private void addCron(String name, Instrumentation.Cron cron) {
188            instrumentation.addCron(INSTRUMENTATION_GROUP, name, cron);
189        }
190    
191        /**
192         * Start the request instrumentation cron.
193         */
194        protected void startCron() {
195            requestCron.get().start();
196        }
197    
198        /**
199         * Stop the request instrumentation cron.
200         */
201        protected void stopCron() {
202            requestCron.get().stop();
203        }
204    
205        /**
206         * Initializes total request and servlet request samplers.
207         */
208        public void init(ServletConfig servletConfig) throws ServletException {
209            super.init(servletConfig);
210            instrumentation = Services.get().get(InstrumentationService.class).get();
211            synchronized (JsonRestServlet.class) {
212                if (TOTAL_REQUESTS_SAMPLER_COUNTER == null) {
213                    TOTAL_REQUESTS_SAMPLER_COUNTER = new AtomicLong();
214                    defineSampler(INSTR_TOTAL_REQUESTS_SAMPLER, TOTAL_REQUESTS_SAMPLER_COUNTER);
215                }
216            }
217            defineSampler(instrumentationName, samplerCounter);
218        }
219    
220        /**
221         * Convenience method for instrumentation counters.
222         *
223         * @param name counter name.
224         * @param count count to increment the counter.
225         */
226        private void incrCounter(String name, int count) {
227            if (instrumentation != null) {
228                instrumentation.incr(INSTRUMENTATION_GROUP, name, count);
229            }
230        }
231    
232        /**
233         * Logs audit information for write requests to the audit log.
234         *
235         * @param request the http request.
236         */
237        private void logAuditInfo(HttpServletRequest request) {
238            if (request.getAttribute(AUDIT_OPERATION) != null) {
239                Integer httpStatusCode = (Integer) request.getAttribute(AUDIT_HTTP_STATUS_CODE);
240                httpStatusCode = (httpStatusCode != null) ? httpStatusCode : HttpServletResponse.SC_OK;
241                String status = (httpStatusCode == HttpServletResponse.SC_OK) ? "SUCCESS" : "FAILED";
242                String operation = (String) request.getAttribute(AUDIT_OPERATION);
243                String param = (String) request.getAttribute(AUDIT_PARAM);
244                String user = XLog.Info.get().getParameter(XLogService.USER);
245                String group = XLog.Info.get().getParameter(XLogService.GROUP);
246                String jobId = XLog.Info.get().getParameter(DagXLogInfoService.JOB);
247                String app = XLog.Info.get().getParameter(DagXLogInfoService.APP);
248    
249                String errorCode = (String) request.getAttribute(AUDIT_ERROR_CODE);
250                String errorMessage = (String) request.getAttribute(AUDIT_ERROR_MESSAGE);
251    
252                auditLog
253                        .info(
254                                "USER [{0}], GROUP [{1}], APP [{2}], JOBID [{3}], OPERATION [{4}], PARAMETER [{5}], STATUS [{6}], HTTPCODE [{7}], ERRORCODE [{8}], ERRORMESSAGE [{9}]",
255                                user, group, app, jobId, operation, param, status, httpStatusCode, errorCode, errorMessage);
256            }
257        }
258    
259        /**
260         * Dispatches to super after loginfo and intrumentation handling. In case of errors dispatches error response codes
261         * and does error logging.
262         */
263        @SuppressWarnings("unchecked")
264        protected final void service(HttpServletRequest request, HttpServletResponse response) throws ServletException,
265                IOException {
266            //if (Services.get().isSafeMode() && !request.getMethod().equals("GET") && !allowSafeModeChanges) {
267            if (Services.get().getSystemMode() != SYSTEM_MODE.NORMAL && !request.getMethod().equals("GET") && !allowSafeModeChanges) {
268                sendErrorResponse(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, ErrorCode.E0002.toString(),
269                                  ErrorCode.E0002.getTemplate());
270                return;
271            }
272            Instrumentation.Cron cron = new Instrumentation.Cron();
273            requestCron.set(cron);
274            try {
275                cron.start();
276                validateRestUrl(request.getMethod(), getResourceName(request), request.getParameterMap());
277                XLog.Info.get().clear();
278                String user = getUser(request);
279                TOTAL_REQUESTS_SAMPLER_COUNTER.incrementAndGet();
280                samplerCounter.incrementAndGet();
281                //If trace is enabled then display the request headers
282                XLog log = XLog.getLog(getClass());
283                if (log.isTraceEnabled()){
284                 logHeaderInfo(request);
285                }
286                super.service(request, response);
287            }
288            catch (XServletException ex) {
289                XLog log = XLog.getLog(getClass());
290                log.warn("URL[{0} {1}] error[{2}], {3}", request.getMethod(), getRequestUrl(request), ex.getErrorCode(), ex
291                        .getMessage(), ex);
292                request.setAttribute(AUDIT_ERROR_MESSAGE, ex.getMessage());
293                request.setAttribute(AUDIT_ERROR_CODE, ex.getErrorCode().toString());
294                request.setAttribute(AUDIT_HTTP_STATUS_CODE, ex.getHttpStatusCode());
295                incrCounter(INSTR_TOTAL_FAILED_REQUESTS_COUNTER, 1);
296                sendErrorResponse(response, ex.getHttpStatusCode(), ex.getErrorCode().toString(), ex.getMessage());
297            }
298            catch (AccessControlException ex) {
299                XLog log = XLog.getLog(getClass());
300                log.error("URL[{0} {1}] error, {2}", request.getMethod(), getRequestUrl(request), ex.getMessage(), ex);
301                incrCounter(INSTR_TOTAL_FAILED_REQUESTS_COUNTER, 1);
302                sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, ErrorCode.E1400.toString(),
303                                  ex.getMessage());
304            }
305            catch (RuntimeException ex) {
306                XLog log = XLog.getLog(getClass());
307                log.error("URL[{0} {1}] error, {2}", request.getMethod(), getRequestUrl(request), ex.getMessage(), ex);
308                incrCounter(INSTR_TOTAL_FAILED_REQUESTS_COUNTER, 1);
309                throw ex;
310            }
311            finally {
312                logAuditInfo(request);
313                TOTAL_REQUESTS_SAMPLER_COUNTER.decrementAndGet();
314                incrCounter(INSTR_TOTAL_REQUESTS_COUNTER, 1);
315                samplerCounter.decrementAndGet();
316                XLog.Info.remove();
317                cron.stop();
318                // TODO
319                incrCounter(instrumentationName, 1);
320                incrCounter(instrumentationName + "-" + request.getMethod(), 1);
321                addCron(instrumentationName, cron);
322                addCron(instrumentationName + "-" + request.getMethod(), cron);
323                requestCron.remove();
324            }
325        }
326    
327        private void logHeaderInfo(HttpServletRequest request){
328            XLog log = XLog.getLog(getClass());
329            StringBuilder traceInfo = new StringBuilder(4096);
330                //Display request URL and request.getHeaderNames();
331                Enumeration<String> names = (Enumeration<String>) request.getHeaderNames();
332                traceInfo.append("Request URL: ").append(getRequestUrl(request)).append("\nRequest Headers:\n");
333                while (names.hasMoreElements()) {
334                    String name = names.nextElement();
335                    String value = request.getHeader(name);
336                    traceInfo.append(name).append(" : ").append(value).append("\n");
337                }
338                log.trace(traceInfo);
339        }
340    
341        private String getRequestUrl(HttpServletRequest request) {
342            StringBuffer url = request.getRequestURL();
343            if (request.getQueryString() != null) {
344                url.append("?").append(request.getQueryString());
345            }
346            return url.toString();
347        }
348    
349        /**
350         * Sends a JSON response.
351         *
352         * @param response servlet response.
353         * @param statusCode HTTP status code.
354         * @param bean bean to send as JSON response.
355         * @param timeZoneId time zone to use for dates in the JSON response.
356         * @throws java.io.IOException thrown if the bean could not be serialized to the response output stream.
357         */
358        protected void sendJsonResponse(HttpServletResponse response, int statusCode, JsonBean bean, String timeZoneId) 
359                throws IOException {
360            response.setStatus(statusCode);
361            JSONObject json = bean.toJSONObject(timeZoneId);
362            response.setContentType(JSTON_UTF8);
363            json.writeJSONString(response.getWriter());
364        }
365    
366        /**
367         * Sends a error response.
368         *
369         * @param response servlet response.
370         * @param statusCode HTTP status code.
371         * @param error error code.
372         * @param message error message.
373         * @throws java.io.IOException thrown if the error response could not be set.
374         */
375        protected void sendErrorResponse(HttpServletResponse response, int statusCode, String error, String message)
376                throws IOException {
377            response.setHeader(RestConstants.OOZIE_ERROR_CODE, error);
378            response.setHeader(RestConstants.OOZIE_ERROR_MESSAGE, message);
379            response.sendError(statusCode);
380        }
381    
382        protected void sendJsonResponse(HttpServletResponse response, int statusCode, JSONStreamAware json)
383                throws IOException {
384            if (statusCode == HttpServletResponse.SC_OK || statusCode == HttpServletResponse.SC_CREATED) {
385                response.setStatus(statusCode);
386            }
387            else {
388                response.sendError(statusCode);
389            }
390            response.setStatus(statusCode);
391            response.setContentType(JSTON_UTF8);
392            json.writeJSONString(response.getWriter());
393        }
394    
395        /**
396         * Validates REST URL using the ResourceInfos of the servlet.
397         *
398         * @param method HTTP method.
399         * @param resourceName resource name.
400         * @param queryStringParams query string parameters.
401         * @throws javax.servlet.ServletException thrown if the resource name or parameters are incorrect.
402         */
403        @SuppressWarnings("unchecked")
404        protected void validateRestUrl(String method, String resourceName, Map<String, String[]> queryStringParams)
405                throws ServletException {
406    
407            if (resourceName.contains("/")) {
408                throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0301, resourceName);
409            }
410    
411            boolean valid = false;
412            for (int i = 0; !valid && i < resourcesInfo.size(); i++) {
413                ResourceInfo resourceInfo = resourcesInfo.get(i);
414                if (resourceInfo.name.equals(resourceName) || resourceInfo.wildcard) {
415                    if (!resourceInfo.methods.contains(method)) {
416                        throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0301, resourceName);
417                    }
418                    for (Map.Entry<String, String[]> entry : queryStringParams.entrySet()) {
419                        String name = entry.getKey();
420                        ParameterInfo parameterInfo = resourceInfo.parameters.get(name);
421                        if (parameterInfo != null) {
422                            if (!parameterInfo.methods.contains(method)) {
423                                throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0302, name);
424                            }
425                            String value = entry.getValue()[0].trim();
426                            if (parameterInfo.type.equals(Boolean.class)) {
427                                value = value.toLowerCase();
428                                if (!value.equals("true") && !value.equals("false")) {
429                                    throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0304, name,
430                                                                "boolean");
431                                }
432                            }
433                            if (parameterInfo.type.equals(Integer.class)) {
434                                try {
435                                    Integer.parseInt(value);
436                                }
437                                catch (NumberFormatException ex) {
438                                    throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0304, name,
439                                                                "integer");
440                                }
441                            }
442                        }
443                    }
444                    for (ParameterInfo parameterInfo : resourceInfo.parameters.values()) {
445                        if (parameterInfo.methods.contains(method) && parameterInfo.required
446                                && queryStringParams.get(parameterInfo.name) == null) {
447                            throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0305,
448                                                        parameterInfo.name);
449                        }
450                    }
451                    valid = true;
452                }
453            }
454            if (!valid) {
455                throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0301, resourceName);
456            }
457        }
458    
459        /**
460         * Return the resource name of the request. <p/> The resource name is the whole extra path. If the extra path starts
461         * with '/', the first '/' is trimmed.
462         *
463         * @param request request instance
464         * @return the resource name, <code>null</code> if none.
465         */
466        protected String getResourceName(HttpServletRequest request) {
467            String requestPath = request.getPathInfo();
468            if (requestPath != null) {
469                while (requestPath.startsWith("/")) {
470                    requestPath = requestPath.substring(1);
471                }
472                requestPath = requestPath.trim();
473            }
474            else {
475                requestPath = "";
476            }
477            return requestPath;
478        }
479    
480        /**
481         * Return the request content type, lowercase and without attributes.
482         *
483         * @param request servlet request.
484         * @return the request content type, <code>null</code> if none.
485         */
486        protected String getContentType(HttpServletRequest request) {
487            String contentType = request.getContentType();
488            if (contentType != null) {
489                int index = contentType.indexOf(";");
490                if (index > -1) {
491                    contentType = contentType.substring(0, index);
492                }
493                contentType = contentType.toLowerCase();
494            }
495            return contentType;
496        }
497    
498        /**
499         * Validate and return the content type of the request.
500         *
501         * @param request servlet request.
502         * @param expected expected contentType.
503         * @return the normalized content type (lowercase and without modifiers).
504         * @throws XServletException thrown if the content type is invalid.
505         */
506        protected String validateContentType(HttpServletRequest request, String expected) throws XServletException {
507            String contentType = getContentType(request);
508            if (contentType == null || contentType.trim().length() == 0) {
509                throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0300, contentType);
510            }
511            if (!contentType.equals(expected)) {
512                throw new XServletException(HttpServletResponse.SC_BAD_REQUEST, ErrorCode.E0300, contentType);
513            }
514            return contentType;
515        }
516    
517        /**
518         * Request attribute constant for the authenticatio token.
519         */
520        public static final String AUTH_TOKEN = "oozie.auth.token";
521    
522        /**
523         * Request attribute constant for the user name.
524         */
525        public static final String USER_NAME = "oozie.user.name";
526    
527        protected static final String UNDEF = "?";
528    
529        /**
530         * Return the user name of the request if any.
531         *
532         * @param request request.
533         * @return the user name, <code>null</code> if there is none.
534         */
535        protected String getUser(HttpServletRequest request) {
536            String userName = (String) request.getAttribute(USER_NAME);
537    
538            String doAsUserName = request.getParameter(RestConstants.DO_AS_PARAM);
539            if (doAsUserName != null && !doAsUserName.equals(userName)) {
540                ProxyUserService proxyUser = Services.get().get(ProxyUserService.class);
541                try {
542                    proxyUser.validate(userName, HostnameFilter.get(), doAsUserName);
543                }
544                catch (IOException ex) {
545                    throw new RuntimeException(ex);
546                }
547                auditLog.info("Proxy user [{0}] DoAs user [{1}] Request [{2}]", userName, doAsUserName,
548                              getRequestUrl(request));
549    
550                XLog.Info.get().setParameter(XLogService.USER, userName + " doAs " + doAsUserName);
551    
552                userName = doAsUserName;
553            }
554            else {
555                XLog.Info.get().setParameter(XLogService.USER, userName);
556            }
557            return (userName != null) ? userName : UNDEF;
558        }
559    
560        /**
561         * Set the log info with the given information.
562         *
563         * @param jobid job ID.
564         * @param actionid action ID.
565         */
566        protected void setLogInfo(String jobid, String actionid) {
567            logInfo.setParameter(DagXLogInfoService.JOB, jobid);
568            logInfo.setParameter(DagXLogInfoService.ACTION, actionid);
569    
570            XLog.Info.get().setParameters(logInfo);
571        }
572    }