/*
* 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 );
}
}
}