/* * Copyright (C) 2011 Laurent Caillette * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation, either * version 3 of the License, or (at your option) any later version. * * 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. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.novelang.produce; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import org.apache.commons.lang.StringUtils; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import org.novelang.common.metadata.PageIdentifier; import org.novelang.designator.Tag; import org.novelang.logger.Logger; import org.novelang.logger.LoggerFactory; import org.novelang.outfit.loader.ResourceName; import org.novelang.rendering.RawResource; import org.novelang.rendering.RenderingTools; import org.novelang.rendering.RenditionMimeType; /** * Unique implementation encapsulating the request for a rendered document or a resource. * * TODO: use ANTLR for parsing. * * @author Laurent Caillette */ public final class GenericRequest implements DocumentRequest, ResourceRequest { private static final Logger LOGGER = LoggerFactory.getLogger( GenericRequest.class ) ; public static final String ERRORPAGE_SUFFIX = "/error.html"; public static final String TAGSET_PARAMETER_NAME= "tags" ; public static final ImmutableSet< String > SUPPORTED_PARAMETER_NAMES = ImmutableSet.of( ALTERNATE_STYLESHEET_PARAMETER_NAME, TAGSET_PARAMETER_NAME ) ; /** * <a href="http://www.ietf.org/rfc/rfc2396.txt" >RFC</a> p. 26-27. */ public static final String LIST_SEPARATOR = ";" ; // ======= // For all // ======= private final String originalTarget ; @Override public String getOriginalTarget() { return originalTarget ; } private final String documentSourceName ; @Override public String getDocumentSourceName() { return documentSourceName; } private final boolean rendered ; @Override public boolean isRendered() { return rendered ; } // ======== // Rendered // ======== private final RenditionMimeType renditionMimeType ; @Override public RenditionMimeType getRenditionMimeType() { return renditionMimeType ; } private final ResourceName alternateStylesheet ; @Override public ResourceName getAlternateStylesheet() { return alternateStylesheet ; } private final ImmutableSet< Tag > tags ; @Override public ImmutableSet< Tag > getTags() { return tags ; } private final boolean displayProblems ; @Override public boolean getDisplayProblems() { return displayProblems ; } private final PageIdentifier pageIdentifier ; @Override public PageIdentifier getPageIdentifier() { return pageIdentifier ; } // ============ // Non-rendered // ============ private final String resourceExtension ; /** * Always null if {@link #isRendered()} is true. * Never null nor blank if {@link #isRendered()} is false. * * @return a {@code String} that may be null, but never blank. */ @Override public String getResourceExtension() { return resourceExtension ; } // ============ // Constructors // ============ private GenericRequest( final String originalTarget, final String documentSourceName, final boolean displayProblems, final RenditionMimeType renditionMimeType, final PageIdentifier pageIdentifier, final ResourceName alternateStylesheet, final ImmutableSet<Tag> tags ) { this.pageIdentifier = pageIdentifier; checkHasCharacters( originalTarget ) ; checkHasCharacters( documentSourceName ) ; this.documentSourceName = documentSourceName ; this.rendered = true ; this.renditionMimeType = checkNotNull( renditionMimeType ) ; this.alternateStylesheet = alternateStylesheet ; this.tags = checkNotNull( tags ) ; this.displayProblems = displayProblems ; this.resourceExtension = null ; this.originalTarget = rebuildOriginalTarget( false ) ; } private String rebuildOriginalTarget( final boolean addProblemPage ) { final ImmutableList.Builder< String > parametersBuilder = ImmutableList.builder() ; if( alternateStylesheet != null ) { parametersBuilder.add( ALTERNATE_STYLESHEET_PARAMETER_NAME + "=" + alternateStylesheet.getName() ) ; } if( ! getTags().isEmpty() ) { final Iterable< String > tagNames = Iterables.transform( getTags(), Tag.EXTRACT_TAG_NAME ) ; parametersBuilder.add( TAGSET_PARAMETER_NAME + "=" + Joiner.on( LIST_SEPARATOR ).join( tagNames ) ) ; } final ImmutableList< String > parameters = parametersBuilder.build() ; return documentSourceName + ( pageIdentifier == null ? "" : PAGEIDENTIFIER_PREFIX + pageIdentifier.getName() ) + "." + renditionMimeType.getFileExtension() + ( addProblemPage ? ERRORPAGE_SUFFIX : "" ) + ( parameters.isEmpty() ? "" : "?" + Joiner.on( "&" ).join( parameters ) ) ; } private GenericRequest( final String originalTarget, final String documentSourceName, final String resourceExtension ) { checkHasCharacters( originalTarget ) ; this.originalTarget = documentSourceName + "." + resourceExtension ; checkHasCharacters( documentSourceName ) ; this.documentSourceName = documentSourceName ; this.rendered = false ; checkHasCharacters( resourceExtension ) ; this.resourceExtension = resourceExtension ; renditionMimeType = null ; pageIdentifier = null ; alternateStylesheet = null ; tags = null ; displayProblems = false ; } private static void checkHasCharacters( final String string ) { checkArgument( ! StringUtils.isBlank( string ) ) ; } // ======= // Parsing // ======= private static final String TAG_PATTERN = "[a-zA-Z0-9][a-zA-Z0-9\\-_]*" ; /** * Allow non-word characters only in the middle of word characters and if they are not * consecutive. */ private static final String PATH_SEGMENT_PATTERN = "[A-Za-z0-9]+(?:(?:-|_|\\.)[A-Za-z0-9]+)*" ; private static Pattern createPattern() { final StringBuilder buffer = new StringBuilder() ; buffer.append( "(" ) ; // The path without extension. No double dots for security reasons (forbid '..'). buffer.append( "((?:\\/" + PATH_SEGMENT_PATTERN + ")+)" ) ; // Page identifier. buffer.append( "(?:--(" ) ; buffer.append( PageIdentifier.PATTERN.pattern() ) ; buffer.append( "))?" ); // The extension defining the MIME type. buffer.append( "(?:\\.(" ) ; final ImmutableList< String > allExtensions = ImmutableList.< String >builder() .addAll( RenditionMimeType.getFileExtensions() ) .addAll( RawResource.getFileExtensions() ) .build() ; buffer.append( Joiner.on( "|" ).join( allExtensions ) ) ; buffer.append( "))" ) ; buffer.append( ")" ) ; // This duplicates the 'tag' rule in ANTLR grammar. Shame. final String parameter = "([a-zA-Z0-9\\-\\=_&\\./" + LIST_SEPARATOR + "]+)" ; buffer.append( "(?:\\?" ) ; buffer.append( parameter ) ; buffer.append( ")?" ) ; return Pattern.compile( buffer.toString() ) ; } private static final Pattern DOCUMENT_PATTERN = createPattern() ; static { LOGGER.debug( "Crafted regex: ", DOCUMENT_PATTERN.pattern() ) ; } private static String extractExtension( final String path ) throws MalformedRequestException { final Matcher matcher = DOCUMENT_PATTERN.matcher( path ) ; if( matcher.find() && matcher.groupCount() >= 4 ) { return matcher.group( 4 ) ; } else { throw new MalformedRequestException( "Doesn't contain an extension: '" + path + "'" ) ; } } private static ImmutableMap< String, String > getQueryMap( final String query ) throws MalformedRequestException { if( StringUtils.isBlank( query ) ) { return ImmutableMap.of() ; } else { final Iterable< String > params = Splitter.on( '&' ).split( query ) ; final ImmutableMap.Builder< String, String > map = ImmutableMap.builder() ; for( final String param : params ) { final ImmutableList< String > strings = ImmutableList.copyOf( Splitter.on( '=' ).split( param ) ) ; final String name = strings.get( 0 ) ; if( strings.size() > 2 ) { throw new MalformedRequestException( "Multiple '=' for parameter " + name ) ; } final String value ; if( strings.size() > 1 ) { value = strings.get( 1 ) ; } else { value = null ; } if( map.build().keySet().contains( name ) ) { throw new MalformedRequestException( "Duplicate value for parameter " + name ) ; } map.put( name, value ) ; } return map.build() ; } } private static ResourceName extractResourceName( final ImmutableMap< String, String > parameterMap ) { final String parameterValue = parameterMap.get( ALTERNATE_STYLESHEET_PARAMETER_NAME ) ; if( parameterValue == null ) { return null ; } else { return new ResourceName( parameterValue ) ; } } private static final Pattern TAGS_PATTERN = Pattern.compile( TAG_PATTERN + "(?:" + LIST_SEPARATOR + TAG_PATTERN + ")*" ) ; private static final Pattern TAGS_SEPARATOR_PATTERN = Pattern.compile( LIST_SEPARATOR ) ; private static ImmutableSet< Tag > parseTags( final String value ) throws MalformedRequestException { if( TAGS_PATTERN.matcher( value ).matches() ) { return RenderingTools.toTagSet( ImmutableSet.copyOf( TAGS_SEPARATOR_PATTERN.split( value ) ) ) ; } else { throw new MalformedRequestException( "Bad tags: '" + value + "'" ) ; } } private static ImmutableSet< Tag > extractTags( final ImmutableMap< String, String > parameterMap ) throws MalformedRequestException { final String parameterValue = parameterMap.get( TAGSET_PARAMETER_NAME ) ; if( parameterValue == null ) { return ImmutableSet.of() ; } else { return parseTags( parameterValue ) ; } } private static void verifyAllParameterNames( final Set< String > parameterNames ) throws MalformedRequestException { for( final String parameterName : parameterNames ) { if( ! SUPPORTED_PARAMETER_NAMES.contains( parameterName ) ) { throw new MalformedRequestException( "Unsupported query parameter: '" + parameterName + "'" ) ; } } } public static AnyRequest parse( final String originalTarget ) throws MalformedRequestException { final Matcher matcher = DOCUMENT_PATTERN.matcher( originalTarget ) ; if( matcher.find() && matcher.groupCount() >= 4 ) { // Document source name plus extension, minus page identifier. final String fullTarget = matcher.group( 2 ) + "." + matcher.group( 4 ) ; final boolean showProblems = fullTarget.endsWith( ERRORPAGE_SUFFIX ) ; final String targetMinusError ; if( showProblems ) { targetMinusError = fullTarget.substring( 0, fullTarget.length() - ERRORPAGE_SUFFIX.length() ) ; } else { targetMinusError = fullTarget ; } final String rawDocumentMimeType = extractExtension( targetMinusError ) ; final String rawDocumentSourceName = targetMinusError.substring( 0, targetMinusError.length() - rawDocumentMimeType.length() - 1 ) ; final String maybePageIdentifier = matcher.group( 3 ); final PageIdentifier pageIdentifier = maybePageIdentifier == null ? null : new PageIdentifier( maybePageIdentifier ) ; final RenditionMimeType renditionMimeType = RenditionMimeType.maybeValueOf( rawDocumentMimeType == null ? null : rawDocumentMimeType.toUpperCase() ) ; final ImmutableMap< String, String > parameterMap = matcher.groupCount() >= 5 ? getQueryMap( matcher.group( 5 ) ) : ImmutableMap.< String, String >of() ; verifyAllParameterNames( parameterMap.keySet() ) ; final ResourceName alternateStylesheet = extractResourceName( parameterMap ) ; final ImmutableSet< Tag > tagset = extractTags( parameterMap ) ; final AnyRequest request ; if( renditionMimeType == null ) { request = new GenericRequest( originalTarget, rawDocumentSourceName, rawDocumentMimeType ) ; } else { request = new GenericRequest( originalTarget, rawDocumentSourceName, showProblems, renditionMimeType, pageIdentifier, alternateStylesheet, tagset ) ; } return request ; } else { throw new MalformedRequestException( "Could not parse: '" + originalTarget + "'." ) ; } } // ========= // Utilities // ========= /** * Return the document name, plus the page identifier if any. * @param documentRequest a non-null object. * @return a non-null, non-empty {@code String}. */ public static String getDocumentNameWithPageIdentifier( final DocumentRequest documentRequest ) { return documentRequest.getDocumentSourceName() + ( documentRequest.getPageIdentifier() == null ? "" : DocumentRequest.PAGEIDENTIFIER_PREFIX + documentRequest.getPageIdentifier().getName() ) ; } /** * Return the URL path and parameters for an error page. * @param documentRequest a non-null object. Must be a {@link GenericRequest} instance. * @return a non-null, non-empty {@code String}. */ public static String getRedirectionWithError( final DocumentRequest documentRequest ) { return ( ( GenericRequest ) documentRequest ).rebuildOriginalTarget( true ) ; } // ================ // java.lang.Object // ================ @Override public String toString() { final StringBuilder stringBuilder = new StringBuilder( getClass().getSimpleName() + "[" ) ; if( isRendered() && getDisplayProblems() ) { stringBuilder.append( "displayProblems=true; " ) ; } stringBuilder.append( getOriginalTarget() ) ; stringBuilder.append( "]" ) ; return stringBuilder.toString() ; } @Override public boolean equals( final Object other ) { if( this == other ) { return true ; } if( other == null || getClass() != other.getClass() ) { return false ; } final GenericRequest that = ( GenericRequest ) other ; if( displayProblems != that.displayProblems ) { return false ; } if( rendered != that.rendered ) { return false ; } if( alternateStylesheet != null ? ! alternateStylesheet.equals( that.alternateStylesheet ) : that.alternateStylesheet != null ) { return false ; } if( !documentSourceName.equals( that.documentSourceName ) ) { return false ; } if( !originalTarget.equals( that.originalTarget ) ) { return false ; } if( renditionMimeType != that.renditionMimeType ) { return false ; } if( resourceExtension != null ? !resourceExtension.equals( that.resourceExtension ) : that.resourceExtension != null ) { return false ; } if( tags != null ? !tags.equals( that.tags ) : that.tags != null ) { return false ; } return true ; } @Override public int hashCode() { int result = originalTarget.hashCode() ; result = 31 * result + documentSourceName.hashCode(); result = 31 * result + ( pageIdentifier != null ? pageIdentifier.hashCode() : 0 ) ; result = 31 * result + ( rendered ? 1 : 0 ) ; result = 31 * result + ( renditionMimeType != null ? renditionMimeType.hashCode() : 0 ) ; result = 31 * result + ( alternateStylesheet != null ? alternateStylesheet.hashCode() : 0 ) ; result = 31 * result + ( tags != null ? tags.hashCode() : 0 ) ; result = 31 * result + ( displayProblems ? 1 : 0 ) ; result = 31 * result + ( resourceExtension != null ? resourceExtension.hashCode() : 0 ) ; return result ; } }