/* * This program is free software; you can redistribute it and/or modify it under the * terms of the GNU General Public License, version 2 as published by the Free Software * Foundation. * * You should have received a copy of the GNU General Public License along with this * program; if not, you can obtain a copy at http://www.gnu.org/licenses/gpl-2.0.html * or from the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the GNU General Public License for more details. * * * Copyright 2006 - 2016 Pentaho Corporation. All rights reserved. */ package org.pentaho.reporting.platform.plugin; import org.apache.commons.io.IOUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.codehaus.jackson.annotate.JsonPropertyOrder; import org.codehaus.jackson.map.ObjectMapper; import org.pentaho.platform.api.engine.IPentahoSession; import org.pentaho.platform.api.repository2.unified.IUnifiedRepository; import org.pentaho.platform.api.repository2.unified.RepositoryFile; import org.pentaho.platform.engine.core.system.PentahoSessionHolder; import org.pentaho.platform.engine.core.system.PentahoSystem; import org.pentaho.platform.util.RepositoryPathEncoder; import org.pentaho.platform.util.StringUtil; import org.pentaho.platform.util.web.MimeHelper; import org.pentaho.reporting.engine.classic.core.MasterReport; import org.pentaho.reporting.libraries.resourceloader.ResourceException; import org.pentaho.reporting.platform.plugin.async.AsyncExecutionStatus; import org.pentaho.reporting.platform.plugin.async.IAsyncReportState; import org.pentaho.reporting.platform.plugin.async.IJobIdGenerator; import org.pentaho.reporting.platform.plugin.async.IPentahoAsyncExecutor; import org.pentaho.reporting.platform.plugin.async.ISchedulingDirectoryStrategy; import org.pentaho.reporting.platform.plugin.staging.IFixedSizeStreamingContent; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.CacheControl; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.StreamingOutput; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Collections; import java.util.UUID; import java.util.concurrent.Future; @Path( "/reporting/api/jobs" ) public class JobManager { private static final Log logger = LogFactory.getLog( JobManager.class ); private static final String ASYNC_DISABLED = "JobManager initialization: async mode marked as disabled."; private static final String ERROR_GENERATING_REPORT = "Error generating report"; private static final String UNABLE_TO_SERIALIZE_TO_JSON = "Unable to serialize to json : "; private static final String UNCKNOWN_MEDIA_TYPE = "Can't determine JAX-RS media type for: "; private final Config config; public JobManager() { this( true, 500, 1500, false ); } public JobManager( final boolean isSupportAsync, final long pollingIntervalMilliseconds, final long dialogThresholdMillisecond ) { this( isSupportAsync, pollingIntervalMilliseconds, dialogThresholdMillisecond, false ); } public JobManager( final boolean isSupportAsync, final long pollingIntervalMilliseconds, final long dialogThresholdMillisecond, final boolean promptForLocation ) { if ( !isSupportAsync ) { logger.info( ASYNC_DISABLED ); } this.config = new Config( isSupportAsync, pollingIntervalMilliseconds, dialogThresholdMillisecond, promptForLocation ); } @GET @Path( "config" ) public Response getConfig() { return getJson( config ); } @GET @Path( "{job_id}/content" ) public Response getPDFContent( @PathParam( "job_id" ) final String job_id ) throws IOException { logger.debug( "Chrome pdf viewer workaround. See BACKLOG-7598 for details" ); return this.getContent( job_id ); } @SuppressWarnings( "unchecked" ) @POST @Path( "{job_id}/content" ) public Response getContent( @PathParam( "job_id" ) final String jobId ) throws IOException { try { final ExecutionContext context = getContext( jobId ); final Future<IFixedSizeStreamingContent> future = context.getFuture(); final IAsyncReportState state = context.getReportState(); if ( !AsyncExecutionStatus.FINISHED.equals( state.getStatus() ) ) { return Response.status( Response.Status.ACCEPTED ).build(); } final IFixedSizeStreamingContent input; try { input = future.get(); } catch ( final Exception e ) { logger.error( ERROR_GENERATING_REPORT, e ); return Response.serverError().build(); } final StreamingOutput stream = new StreamingOutputWrapper( input.getStream() ); MediaType mediaType; Response.ResponseBuilder response; try { mediaType = MediaType.valueOf( state.getMimeType() ); } catch ( final Exception e ) { logger.warn( UNCKNOWN_MEDIA_TYPE + state.getMimeType(), e ); //Downloadable type mediaType = MediaType.APPLICATION_OCTET_STREAM_TYPE; } response = Response.ok( stream, mediaType ); response = noCache( response ); response = calculateContentDisposition( response, state ); return response.build(); } catch ( final ContextFailedException | FutureNotFoundException e ) { return get404(); } } @GET @Path( "{job_id}/status" ) @Produces( "application/json" ) public Response getStatus( @PathParam( "job_id" ) final String jobId ) { try { final ExecutionContext context = getContext( jobId ); final IAsyncReportState responseJson = context.getReportState(); return getJson( responseJson ); } catch ( final ContextFailedException e ) { return get404(); } } private Response getJson( final Object responseJson ) { final ObjectMapper mapper = new ObjectMapper(); String json = null; try { json = mapper.writeValueAsString( responseJson ); } catch ( final Exception e ) { logger.error( UNABLE_TO_SERIALIZE_TO_JSON + responseJson.toString() ); Response.serverError().build(); } return Response.ok( json ).build(); } protected IPentahoAsyncExecutor getExecutor() { return PentahoSystem.get( IPentahoAsyncExecutor.class ); } @SuppressWarnings( "unchecked" ) @GET @Path( "{job_id}/cancel" ) public Response cancel( @PathParam( "job_id" ) final String jobId ) { try { final ExecutionContext context = getContext( jobId ); final Future<InputStream> future = context.getFuture(); final IAsyncReportState state = context.getReportState(); logger.debug( "Cancellation of report: " + state.getPath() + ", requested by : " + context.getSession() ); future.cancel( true ); return Response.ok().build(); } catch ( final ContextFailedException e ) { return get404(); } catch ( final FutureNotFoundException e ) { return Response.ok().build(); } } @GET @Path( "{job_id}/requestPage/{page}" ) @Produces( "text/text" ) public Response requestPage( @PathParam( "job_id" ) final String jobId, @PathParam( "page" ) final int page ) { try { final ExecutionContext context = getContext( jobId ); context.requestPage( page ); return Response.ok( String.valueOf( page ) ).build(); } catch ( final ContextFailedException e ) { return get404(); } } @GET @Path( "{job_id}/schedule" ) @Produces( "text/text" ) public Response schedule( @PathParam( "job_id" ) final String jobId, @DefaultValue( "true" ) @QueryParam( "confirm" ) final boolean confirm ) { try { ExecutionContext context = getContext( jobId ); if ( confirm ) { if ( context.needRecalculation( Boolean.FALSE ) ) { //Get new job id final UUID recalculate = context.recalculate(); if ( null != recalculate ) { context = getContext( recalculate.toString() ); } } context.schedule(); } else { context.preSchedule(); } return Response.ok().build(); } catch ( final ContextFailedException e ) { return get404(); } } @POST @Path( "{job_id}/schedule" ) @Produces( "application/json" ) public Response confirmSchedule( @PathParam( "job_id" ) final String jobId, @DefaultValue( "true" ) @QueryParam( "confirm" ) final boolean confirm, @DefaultValue( "false" ) @QueryParam( "recalculateFinished" ) final boolean recalculateFinished, @QueryParam( "folderId" ) final String folderId, @QueryParam( "newName" ) final String newName ) { try { //We can't go further without folder id and file name if ( StringUtil.isEmpty( folderId ) || StringUtil.isEmpty( newName ) ) { return get404(); } ExecutionContext context = getContext( jobId ); //The report can be already scheduled but we still may want to update the location if ( confirm ) { if ( context.needRecalculation( recalculateFinished ) ) { //Get new job id final UUID recalculate = context.recalculate(); if ( null != recalculate ) { context = getContext( recalculate.toString() ); } } context.schedule(); } //Update the location context.updateSchedulingLocation( folderId, newName ); return getJson( Collections.singletonMap( "uuid", context.jobId ) ); } catch ( final ContextFailedException e ) { return get404(); } } public ExecutionContext getContext( final String jobId ) throws ContextFailedException { final ExecutionContext executionContext = new ExecutionContext( jobId ); executionContext.evaluate(); return executionContext; } @POST @Path( "reserveId" ) @Produces( "application/json" ) public Response reserveId() { final IPentahoSession session = PentahoSessionHolder.getSession(); final IJobIdGenerator iJobIdGenerator = PentahoSystem.get( IJobIdGenerator.class ); if ( session != null && iJobIdGenerator != null ) { final UUID reservedId = iJobIdGenerator.generateId( session ); return getJson( Collections.singletonMap( "reservedId", reservedId.toString() ) ); } else { return get404(); } } protected final Response get404() { return Response.status( Response.Status.NOT_FOUND ).build(); } /** * In-place implementation to support streaming responses. By default - even InputStream passed - streaming is not * occurs. */ protected static final class StreamingOutputWrapper implements StreamingOutput { private InputStream input; public StreamingOutputWrapper( final InputStream readFrom ) { this.input = readFrom; } @Override public void write( final OutputStream outputStream ) throws IOException, WebApplicationException { try { IOUtils.copy( input, outputStream ); outputStream.flush(); } finally { IOUtils.closeQuietly( outputStream ); IOUtils.closeQuietly( input ); } } } protected static Response.ResponseBuilder noCache( final Response.ResponseBuilder response ) { // no cache final CacheControl cacheControl = new CacheControl(); cacheControl.setPrivate( true ); cacheControl.setMaxAge( 0 ); cacheControl.setMustRevalidate( true ); response.cacheControl( cacheControl ); return response; } protected static Response.ResponseBuilder calculateContentDisposition( final Response.ResponseBuilder response, final IAsyncReportState state ) { final org.pentaho.reporting.libraries.base.util.IOUtils utils = org.pentaho.reporting.libraries .base.util.IOUtils.getInstance(); final String targetExt = MimeHelper.getExtension( state.getMimeType() ); final String fullPath = state.getPath(); final String sourceExt = utils.getFileExtension( fullPath ); String cleanFileName = utils.stripFileExtension( utils.getFileName( fullPath ) ); if ( StringUtil.isEmpty( cleanFileName ) ) { cleanFileName = "content"; } final String disposition = "inline; filename*=UTF-8''" + RepositoryPathEncoder .encode( RepositoryPathEncoder.encodeRepositoryPath( cleanFileName + targetExt ) ); response.header( "Content-Disposition", disposition ); response.header( "Content-Description", cleanFileName + sourceExt ); return response; } @JsonPropertyOrder( alphabetic = true ) //stable response structure private class Config { private final boolean isSupportAsync; private final long pollingIntervalMilliseconds; private final long dialogThresholdMilliseconds; private final boolean promptForLocation; private Config( final boolean isSupportAsync, final long pollingIntervalMilliseconds, final long dialogThresholdMilliseconds, final boolean promptForLocation ) { this.isSupportAsync = isSupportAsync; this.pollingIntervalMilliseconds = pollingIntervalMilliseconds; this.dialogThresholdMilliseconds = dialogThresholdMilliseconds; this.promptForLocation = promptForLocation; } public boolean isSupportAsync() { return isSupportAsync; } public long getPollingIntervalMilliseconds() { return pollingIntervalMilliseconds; } public long getDialogThresholdMilliseconds() { return dialogThresholdMilliseconds; } public boolean isPromptForLocation() { return promptForLocation; } //Location can be changed at any time depending on ISchedulingDirectoryStrategy implementation public String getDefaultOutputPath() { return getLocation(); } } private String getLocation() { final ISchedulingDirectoryStrategy directoryStrategy = PentahoSystem.get( ISchedulingDirectoryStrategy.class ); final IUnifiedRepository repository = PentahoSystem.get( IUnifiedRepository.class ); if ( directoryStrategy != null && repository != null ) { //We may consider caching in strategy implementation final RepositoryFile outputFolder = directoryStrategy.getSchedulingDir( repository ); return outputFolder.getPath(); } return "/"; } /** * Used to get context for operation execution and validate it */ public class ExecutionContext { private IPentahoSession session; private final String jobId; private UUID uuid = null; private ExecutionContext( final String jobId ) { this.jobId = jobId; } private void evaluate() throws ContextFailedException { try { this.session = PentahoSessionHolder.getSession(); this.uuid = UUID.fromString( jobId ); } catch ( final Exception e ) { logger.error( e ); throw new ContextFailedException( e ); } } public IPentahoSession getSession() { return session; } //Be sure to get it from context each time to make it work for PIR too private IPentahoAsyncExecutor getReportExecutor() { return getExecutor(); } public Future getFuture() throws FutureNotFoundException { final Future future = getReportExecutor().getFuture( uuid, session ); if ( future == null ) { throw new FutureNotFoundException( "Can't get future" ); } return future; } public IAsyncReportState getReportState() throws ContextFailedException { final IAsyncReportState reportState = getReportExecutor().getReportState( uuid, session ); if ( reportState == null ) { throw new ContextFailedException( "Can't get state" ); } return reportState; } public void requestPage( final int page ) throws ContextFailedException { //Check if there is a task getReportState(); getReportExecutor().requestPage( uuid, session, page ); } public void schedule() throws ContextFailedException { //Check if there is a task final IAsyncReportState reportState = getReportState(); if ( reportState.getStatus().equals( AsyncExecutionStatus.SCHEDULED ) ) { throw new ContextFailedException( "Report is already scheduled." ); } getReportExecutor().schedule( uuid, session ); } public void updateSchedulingLocation( final String folderId, final String newName ) throws ContextFailedException { if ( !config.isPromptForLocation() ) { throw new ContextFailedException( "Location update is disabled" ); } //Check if there is a task final IAsyncReportState reportState = getReportState(); if ( reportState.getStatus().equals( AsyncExecutionStatus.SCHEDULED ) ) { getReportExecutor().updateSchedulingLocation( uuid, session, folderId, newName ); } else { throw new ContextFailedException( "Can't update the location of not scheduled report." ); } } public void preSchedule() throws ContextFailedException { //Check if there is a task getReportState(); getReportExecutor().preSchedule( uuid, session ); } public UUID recalculate() throws ContextFailedException { //Check if there is a task getReportState(); return getReportExecutor().recalculate( uuid, session ); } public boolean needRecalculation( final boolean recalculateFinished ) throws ContextFailedException { return ( AsyncExecutionStatus.FINISHED.equals( getReportState().getStatus() ) && recalculateFinished ) || isRowLimitRecalculationNeeded(); } private boolean isRowLimitRecalculationNeeded() throws ContextFailedException { try { final IAsyncReportState state = this.getReportState(); final String path = state.getPath(); final MasterReport report = ReportCreator.createReportByName( path ); final int queryLimit = report.getQueryLimit(); if ( queryLimit > 0 ) { return Boolean.TRUE; } else { if ( state.getIsQueryLimitReached() ) { return Boolean.TRUE; } } return Boolean.FALSE; } catch ( ResourceException | IOException e ) { return Boolean.FALSE; } } } public static class ContextFailedException extends Exception { public ContextFailedException( final String message ) { super( message ); } ContextFailedException( final Throwable cause ) { super( cause ); } } private static class FutureNotFoundException extends Exception { public FutureNotFoundException( final String message ) { super( message ); } } }