/** * * Copyright 2009-2011 Rickard Öberg AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.qi4j.library.rest.server.api; import java.io.UnsupportedEncodingException; import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URLDecoder; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import org.qi4j.api.association.ManyAssociation; import org.qi4j.api.common.Optional; import org.qi4j.api.constraint.ConstraintViolation; import org.qi4j.api.constraint.ConstraintViolationException; import org.qi4j.api.constraint.Name; import org.qi4j.api.entity.EntityComposite; import org.qi4j.api.injection.scope.Service; import org.qi4j.api.injection.scope.Structure; import org.qi4j.api.injection.scope.Uses; import org.qi4j.api.property.PropertyDescriptor; import org.qi4j.api.structure.Module; import org.qi4j.api.unitofwork.EntityTypeNotFoundException; import org.qi4j.api.unitofwork.NoSuchEntityException; import org.qi4j.api.value.ValueBuilder; import org.qi4j.api.value.ValueComposite; import org.qi4j.api.value.ValueDescriptor; import org.qi4j.library.rest.common.Resource; import org.qi4j.library.rest.common.link.Link; import org.qi4j.library.rest.server.restlet.ConstraintViolationMessages; import org.qi4j.library.rest.server.restlet.InteractionConstraints; import org.qi4j.library.rest.server.restlet.RequestReaderDelegator; import org.qi4j.library.rest.server.restlet.ResponseWriterDelegator; import org.qi4j.library.rest.server.spi.ResultConverter; import org.qi4j.spi.Qi4jSPI; import org.restlet.Request; import org.restlet.Response; import org.restlet.Uniform; import org.restlet.data.Form; import org.restlet.data.Language; import org.restlet.data.Preference; import org.restlet.data.Reference; import org.restlet.data.Status; import org.restlet.representation.EmptyRepresentation; import org.restlet.representation.Representation; import org.restlet.representation.StringRepresentation; import org.restlet.resource.ResourceException; import org.slf4j.LoggerFactory; import static org.qi4j.api.util.Annotations.isType; import static org.qi4j.functional.Iterables.filter; import static org.qi4j.functional.Iterables.first; import static org.qi4j.functional.Iterables.iterable; import static org.qi4j.library.rest.server.api.ObjectSelection.current; /** * JAVADOC */ public class ContextResource implements Uniform { private static final String ARGUMENTS = "arguments"; public static final String RESOURCE_VALIDITY = "validity"; // API fields @Structure protected Module module; // Private state private Map<String, Method> resourceMethodQueries = new HashMap<String, Method>(); private Map<String, Method> resourceMethodCommands = new HashMap<String, Method>(); private Map<String, Method> subResources = new LinkedHashMap<String, Method>(); private List<Method> resourceQueries = new ArrayList<Method>(); private List<Method> resourceCommands = new ArrayList<Method>(); @Structure private Qi4jSPI spi; @Service private ResponseWriterDelegator responseWriter; @Service private RequestReaderDelegator requestReader; @Service private InteractionConstraints constraints; @Optional @Service private ResultConverter converter; @Uses private ContextRestlet restlet; public ContextResource() { // Resource method mappings for( Method method : getClass().getMethods() ) { if( ContextResource.class.isAssignableFrom( method.getDeclaringClass() ) && !ContextResource.class.equals( method.getDeclaringClass() ) && !method.isSynthetic() ) { if( method.getAnnotation( SubResource.class ) == null ) { Method oldMethod = null; if( isCommand( method ) ) { oldMethod = resourceMethodCommands.put( method.getName().toLowerCase(), method ); resourceCommands.add( method ); } else { oldMethod = resourceMethodQueries.put( method.getName().toLowerCase(), method ); resourceQueries.add( method ); } if( oldMethod != null ) { throw new IllegalStateException( "Two methods in resource " + getClass().getName() + " with same name " + oldMethod .getName() + ", which is not allowed" ); } } else { Method oldMethod = subResources.put( method.getName().toLowerCase(), method ); if( oldMethod != null ) { throw new IllegalStateException( "Two methods in resource " + getClass().getName() + " with same name " + oldMethod .getName() + ", which is not allowed" ); } } } } } // Uniform implementation @Override public final void handle( Request request, Response response ) { ObjectSelection objectSelection = current(); // Check constraints for this resource if( !constraints.isValid( getClass(), objectSelection, module ) ) { throw new ResourceException( Status.CLIENT_ERROR_FORBIDDEN ); } // Find remaining segments List<String> segments = getSegments(); if( segments.size() > 0 ) { String segment = segments.remove( 0 ); if( segments.size() > 0 ) { handleSubResource( segment ); } else { handleResource( segment ); } } } // API methods protected void setResourceValidity( EntityComposite entity ) { Request request = Request.getCurrent(); ResourceValidity validity = new ResourceValidity( entity, spi, request ); request.getAttributes().put( RESOURCE_VALIDITY, validity ); } protected void subResource( Class<? extends ContextResource> subResourceClass ) { restlet.subResource( subResourceClass ); } protected <T> T select( Class<T> entityClass, String id ) throws ResourceException { try { T composite = module.currentUnitOfWork().get( entityClass, id ); current().select( composite ); return composite; } catch( EntityTypeNotFoundException e ) { throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } catch( NoSuchEntityException e ) { throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } } protected <T> T selectFromManyAssociation( ManyAssociation<T> manyAssociation, String id ) throws ResourceException { T entity = (T) module.currentUnitOfWork().get( Object.class, id ); if( !manyAssociation.contains( entity ) ) { throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } current().select( entity ); return entity; /* for( T entity : manyAssociation ) { if( entity.toString().equals( id ) ) { current().select( entity ); return entity; } } throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); */ } protected void selectFromList( List<?> list, String indexString ) { Integer index = Integer.decode( indexString ); if( index < 0 || index >= list.size() ) { throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } current().select( index ); Object value = list.get( index ); current().select( value ); } protected Locale getLocale() { Request request = Request.getCurrent(); List<Preference<Language>> preferenceList = request.getClientInfo().getAcceptedLanguages(); if( preferenceList.isEmpty() ) { return Locale.getDefault(); } Language language = preferenceList .get( 0 ).getMetadata(); String[] localeStr = language.getName().split( "-" ); Locale locale; switch( localeStr.length ) { case 1: locale = new Locale( localeStr[ 0 ] ); break; case 2: locale = new Locale( localeStr[ 0 ], localeStr[ 1 ] ); break; case 3: locale = new Locale( localeStr[ 0 ], localeStr[ 1 ], localeStr[ 2 ] ); break; default: locale = Locale.getDefault(); } return locale; } protected <T> T context( Class<T> contextClass ) { return module.newObject( contextClass, ObjectSelection.current().toArray() ); } // Private implementation private void handleSubResource( String segment ) { if( this instanceof SubResources ) { SubResources subResources = (SubResources) this; try { StringBuilder template = (StringBuilder) Request.getCurrent().getAttributes().get( "template" ); template.append( "resource/" ); subResources.resource( URLDecoder.decode( segment, "UTF-8" ) ); } catch( UnsupportedEncodingException e ) { subResources.resource( segment ); } } else { // Find @SubResource annotated method try { Method method = getSubResourceMethod( segment ); StringBuilder template = (StringBuilder) Request.getCurrent().getAttributes().get( "template" ); template.append( segment ).append( "/" ); method.invoke( this ); } catch( Throwable e ) { handleException( Response.getCurrent(), e ); } } } private Method getSubResourceMethod( String resourceName ) throws ResourceException { Method method = subResources.get( resourceName ); if( method != null ) { return method; } throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } private void resource() { Request request = Request.getCurrent(); Response response = Response.getCurrent(); if( !request.getMethod().equals( org.restlet.data.Method.GET ) ) { response.setStatus( Status.CLIENT_ERROR_METHOD_NOT_ALLOWED ); return; } ObjectSelection objectSelection = current(); // Check for interaction->method mappings if( ResourceDelete.class.isAssignableFrom( getClass() ) ) { response.getAllowedMethods().add( org.restlet.data.Method.DELETE ); } if( ResourceUpdate.class.isAssignableFrom( getClass() ) ) { response.getAllowedMethods().add( org.restlet.data.Method.PUT ); } // Construct resource ValueBuilder<Resource> builder = module.newValueBuilder( Resource.class ); List<Link> queriesProperty = builder.prototype().queries().get(); for( Method query : resourceQueries ) { if( constraints.isValid( query, objectSelection, module ) ) { ValueBuilder<Link> linkBuilder = module.newValueBuilder( Link.class ); Link prototype = linkBuilder.prototype(); prototype.classes().set( "query" ); prototype.text().set( humanReadable( query.getName() ) ); prototype.href().set( query.getName().toLowerCase() ); prototype.rel().set( query.getName().toLowerCase() ); prototype.id().set( query.getName().toLowerCase() ); queriesProperty.add( linkBuilder.newInstance() ); } } List<Link> commandsProperty = builder.prototype().commands().get(); for( Method command : resourceCommands ) { if( constraints.isValid( command, objectSelection, module ) ) { ValueBuilder<Link> linkBuilder = module.newValueBuilder( Link.class ); Link prototype = linkBuilder.prototype(); prototype.classes().set( "command" ); prototype.text().set( humanReadable( command.getName() ) ); prototype.href().set( command.getName().toLowerCase() ); prototype.rel().set( command.getName().toLowerCase() ); prototype.id().set( command.getName().toLowerCase() ); commandsProperty.add( linkBuilder.newInstance() ); } } List<Link> resourcesProperty = builder.prototype().resources().get(); for( Method subResource : subResources.values() ) { if( constraints.isValid( subResource, objectSelection, module ) ) { ValueBuilder<Link> linkBuilder = module.newValueBuilder( Link.class ); Link prototype = linkBuilder.prototype(); prototype.classes().set( "resource" ); prototype.text().set( humanReadable( subResource.getName() ) ); prototype.href().set( subResource.getName().toLowerCase() + "/" ); prototype.rel().set( subResource.getName().toLowerCase() ); prototype.id().set( subResource.getName().toLowerCase() ); resourcesProperty.add( linkBuilder.newInstance() ); } } try { Method indexMethod = resourceMethodQueries.get( "index" ); if( indexMethod != null ) { Object index = convert( indexMethod.invoke( this ) ); if( index != null && index instanceof ValueComposite ) { builder.prototype().index().set( (ValueComposite) index ); } } } catch( Throwable e ) { // Ignore } try { responseWriter.writeResponse( builder.newInstance(), response ); } catch( Throwable e ) { handleException( response, e ); } } private boolean isCommand( Method method ) { return method.getReturnType().equals( Void.TYPE ) || method.getName().equals( "create" ); } /** * Transform a Java name to a human readable string by replacing uppercase characters * with space+toLowerCase(char) * Example: * changeDescription -> Change description * doStuffNow -> Do stuff now * * @param name * * @return */ private String humanReadable( String name ) { StringBuilder humanReadableString = new StringBuilder(); for( int i = 0; i < name.length(); i++ ) { char character = name.charAt( i ); if( i == 0 ) { // Capitalize first character humanReadableString.append( Character.toUpperCase( character ) ); } else if( Character.isLowerCase( character ) ) { humanReadableString.append( character ); } else { humanReadableString.append( ' ' ).append( Character.toLowerCase( character ) ); } } return humanReadableString.toString(); } private void result( Object resultValue ) throws Exception { if( resultValue != null ) { if( !responseWriter.writeResponse( resultValue, Response.getCurrent() ) ) { throw new ResourceException( Status.SERVER_ERROR_INTERNAL, "No result writer for type " + resultValue.getClass() .getName() ); } } } private List<String> getSegments() { return (List<String>) Request.getCurrent().getAttributes().get( "segments" ); } private void handleResource( String segment ) { Request request = Request.getCurrent(); if( segment.equals( "" ) || segment.equals( "." ) ) { StringBuilder template = (StringBuilder) request.getAttributes().get( "template" ); template.append( "resource" ); // Index for this resource resource(); } else { StringBuilder template = (StringBuilder) request.getAttributes().get( "template" ); template.append( segment ); if( resourceMethodCommands.containsKey( segment ) ) { handleCommand( segment ); } else { handleQuery( segment ); } } } private void handleCommand( String segment ) { Request request = Request.getCurrent(); Response response = Response.getCurrent(); // Check if this is a request to show the form for this command Method interactionMethod = resourceMethodCommands.get( segment ); if( shouldShowCommandForm( interactionMethod ) ) { // Show form // TODO This should check if method is idempotent response.getAllowedMethods().add( org.restlet.data.Method.POST ); try { // Check if there is a query with this name - if so invoke it Method queryMethod = resourceMethodQueries.get( segment ); if( queryMethod != null ) { result( queryMethod.invoke( this ) ); } else { request.setMethod( org.restlet.data.Method.POST ); response.setStatus( Status.CLIENT_ERROR_UNPROCESSABLE_ENTITY ); result( formForMethod( interactionMethod ) ); } } catch( Exception e ) { handleException( response, e ); } } else { // Check timestamps ResourceValidity validity = (ResourceValidity) request.getAttributes().get( RESOURCE_VALIDITY ); if( validity != null ) { validity.checkRequest(); } // We have input data - do command // Check method constraints if( !constraints.isValid( interactionMethod, current(), module ) ) { throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } // Create argument Object[] arguments = requestReader.readRequest( Request.getCurrent(), interactionMethod ); Request.getCurrent().getAttributes().put( ARGUMENTS, arguments ); // Invoke method try { Object result = interactionMethod.invoke( this, arguments ); if( result != null ) { if( result instanceof Representation ) { response.setEntity( (Representation) result ); } else { result( convert( result ) ); } } } catch( Throwable e ) { handleException( response, e ); } } } private boolean shouldShowCommandForm( Method interactionMethod ) { // Show form on GET/HEAD if( Request.getCurrent().getMethod().isSafe() ) { return true; } if( interactionMethod.getParameterTypes().length > 0 ) { return !( interactionMethod.getParameterTypes()[ 0 ].equals( Response.class ) || Request.getCurrent() .getEntity() .isAvailable() || Request.getCurrent().getEntityAsText() != null || Request.getCurrent() .getResourceRef() .getQuery() != null ); } return false; } private void handleQuery( String segment ) { Request request = Request.getCurrent(); Response response = Response.getCurrent(); // Query // Try to locate either the query method or command method that should be used Method queryMethod = resourceMethodQueries.get( segment ); if( queryMethod == null ) { queryMethod = resourceMethodCommands.get( segment ); } if( queryMethod == null ) { // Not found as interaction, try SubResource Method resourceMethod = subResources.get( segment ); if( resourceMethod != null && resourceMethod.getAnnotation( SubResource.class ) != null ) { // Found it! Redirect to it response.setStatus( Status.REDIRECTION_FOUND ); response .setLocationRef( new Reference( request .getResourceRef() .toString() + "/" ).toString() ); return; } else { // 404 throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } } // Check if this is a request to show the form for this interaction if( ( request .getMethod() .isSafe() && queryMethod.getParameterTypes().length != 0 && request .getResourceRef() .getQuery() == null ) || ( !request .getMethod() .isSafe() && queryMethod.getParameterTypes().length != 0 && !( request .getEntity() .isAvailable() || request .getResourceRef() .getQuery() != null || queryMethod .getParameterTypes()[ 0 ].equals( Response.class ) ) ) ) { // Show form try { // Tell client to try again response.setStatus( Status.CLIENT_ERROR_UNPROCESSABLE_ENTITY ); response.getAllowedMethods().add( org.restlet.data.Method.GET ); response.getAllowedMethods().add( org.restlet.data.Method.POST ); result( formForMethod( queryMethod ) ); } catch( Exception e ) { handleException( response, e ); } } else { // Check timestamps ResourceValidity validity = (ResourceValidity) request.getAttributes().get( RESOURCE_VALIDITY ); if( validity != null ) { validity.checkRequest(); } // We have input data - do query // Check method constraints if( !constraints.isValid( queryMethod, current(), module ) ) { throw new ResourceException( Status.CLIENT_ERROR_NOT_FOUND ); } try { // Create argument Object[] arguments; if( queryMethod.getParameterTypes().length > 0 ) { try { arguments = requestReader.readRequest( Request.getCurrent(), queryMethod ); if( arguments == null ) { // Show form result( formForMethod( queryMethod ) ); return; } } catch( IllegalArgumentException e ) { // Still missing some values - show form result( formForMethod( queryMethod ) ); return; } } else { // No arguments to this query arguments = new Object[ 0 ]; } // Invoke method Request.getCurrent().getAttributes().put( ARGUMENTS, arguments ); Object result = queryMethod.invoke( this, arguments ); if( result != null ) { if( result instanceof Representation ) { response.setEntity( (Representation) result ); } else { result( convert( result ) ); } } } catch( Throwable e ) { handleException( response, e ); } } } private Object convert( Object result ) { if( converter != null ) { result = converter.convert( result, Request.getCurrent(), (Object[]) Request.getCurrent() .getAttributes() .get( ARGUMENTS ) ); } return result; } private void handleException( Response response, Throwable ex ) { while( ex instanceof InvocationTargetException ) { ex = ex.getCause(); } try { throw ex; } catch( ResourceException e ) { // IAE (or subclasses) are considered client faults response.setEntity( new StringRepresentation( e.getMessage() ) ); response.setStatus( e.getStatus() ); } catch( ConstraintViolationException e ) { try { ConstraintViolationMessages cvm = new ConstraintViolationMessages(); // CVE are considered client faults String messages = ""; Locale locale = ObjectSelection.type( Locale.class ); for( ConstraintViolation constraintViolation : e.constraintViolations() ) { if( !messages.equals( "" ) ) { messages += "\n"; } messages += cvm.getMessage( constraintViolation, locale ); } response.setEntity( new StringRepresentation( messages ) ); response.setStatus( Status.CLIENT_ERROR_UNPROCESSABLE_ENTITY ); } catch( Exception e1 ) { response.setEntity( new StringRepresentation( e.getMessage() ) ); response.setStatus( Status.CLIENT_ERROR_UNPROCESSABLE_ENTITY ); } } catch( IllegalArgumentException e ) { // IAE (or subclasses) are considered client faults response.setEntity( new StringRepresentation( e.getMessage() ) ); response.setStatus( Status.CLIENT_ERROR_UNPROCESSABLE_ENTITY ); } catch( RuntimeException e ) { // RuntimeExceptions are considered server faults LoggerFactory.getLogger( getClass() ).warn( "Exception thrown during processing", e ); response.setEntity( new StringRepresentation( e.getMessage() ) ); response.setStatus( Status.SERVER_ERROR_INTERNAL ); } catch( Exception e ) { // Checked exceptions are considered client faults String s = e.getMessage(); if( s == null ) { s = e.getClass().getSimpleName(); } response.setEntity( new StringRepresentation( s ) ); response.setStatus( Status.CLIENT_ERROR_UNPROCESSABLE_ENTITY ); } catch( Throwable e ) { // Anything else are considered server faults LoggerFactory.getLogger( getClass() ).error( "Exception thrown during processing", e ); response.setEntity( new StringRepresentation( e.getMessage() ) ); response.setStatus( Status.SERVER_ERROR_INTERNAL ); } } private Form formForMethod( Method interactionMethod ) { Form form = new Form(); Form queryAsForm = Request.getCurrent().getResourceRef().getQueryAsForm(); Form entityAsForm = null; Representation representation = Request.getCurrent().getEntity(); if( representation != null && !EmptyRepresentation.class.isInstance( representation ) ) { entityAsForm = new Form( representation ); } else { entityAsForm = new Form(); } Class valueType = interactionMethod.getParameterTypes()[ 0 ]; if( ValueComposite.class.isAssignableFrom( valueType ) ) { ValueDescriptor valueDescriptor = module.valueDescriptor( valueType.getName() ); for( PropertyDescriptor propertyDescriptor : valueDescriptor.state().properties() ) { String value = getValue( propertyDescriptor.qualifiedName().name(), queryAsForm, entityAsForm ); if( value == null ) { Object initialValue = propertyDescriptor.initialValue( module ); if( initialValue != null ) { value = initialValue.toString(); } } form.add( propertyDescriptor.qualifiedName().name(), value ); } } else if( valueType.isInterface() && interactionMethod.getParameterTypes().length == 1 ) { // Single entity as input form.add( "entity", getValue( "entity", queryAsForm, entityAsForm ) ); } else { // Construct form out of individual parameters instead int idx = 0; for( Annotation[] annotations : interactionMethod.getParameterAnnotations() ) { Name name = (Name) first( filter( isType( Name.class ), iterable( annotations ) ) ); String value = getValue( name.value(), queryAsForm, entityAsForm ); String paramName; if( name != null ) { paramName = name.value(); } else { paramName = "param" + idx; } form.add( paramName, value ); idx++; } } return form; } private String getValue( String name, Form queryAsForm, Form entityAsForm ) { String value = queryAsForm.getFirstValue( name ); if( value == null ) { value = entityAsForm.getFirstValue( name ); } return value; } }