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