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