From 3dc54953575b152fef74662dd57a9b362e8af332 Mon Sep 17 00:00:00 2001 From: Jon Brisbin Date: Thu, 8 Mar 2012 15:57:24 -0600 Subject: [PATCH] Initial commit --- .gitignore | 4 + build.gradle | 76 ++ core/build.gradle | 6 + .../data/rest/core/Handler.java | 8 + .../springframework/data/rest/core/Link.java | 14 + .../data/rest/core/SimpleLink.java | 33 + .../data/rest/core/util/BeanUtils.java | 211 ++++ .../core/util/FluentBeanDeserializer.java | 89 ++ .../rest/core/util/FluentBeanSerializer.java | 75 ++ .../data/rest/core/util/FluentBeanUtils.java | 132 +++ .../data/rest/core/util/UriUtils.java | 118 +++ .../data/rest/core/UriUtilsSpec.groovy | 48 + core/src/test/resources/logback.xml | 18 + gradle.properties | 25 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 41664 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 164 ++++ gradlew.bat | 90 ++ repository/build.gradle | 22 + .../rest/repository/JpaEntityMetadata.java | 145 +++ .../repository/JpaRepositoryMetadata.java | 166 ++++ rest/build.gradle | 22 + .../data/rest/mvc/JsonView.java | 84 ++ .../rest/mvc/RepositoryRestConfiguration.java | 61 ++ .../rest/mvc/RepositoryRestController.java | 908 ++++++++++++++++++ .../mvc/RepositoryRestMvcConfiguration.java | 81 ++ ...rverHttpRequestMethodArgumentResolver.java | 31 + .../data/rest/mvc/UriListView.java | 68 ++ rest/src/main/webapp/WEB-INF/repositories.xml | 8 + rest/src/main/webapp/WEB-INF/web.xml | 48 + .../data/rest/test/RestBuilder.java | 225 +++++ .../data/rest/test/mvc/Address.java | 61 ++ .../data/rest/test/mvc/AddressRepository.java | 9 + .../data/rest/test/mvc/Person.java | 77 ++ .../data/rest/test/mvc/PersonLoader.java | 63 ++ .../data/rest/test/mvc/PersonRepository.java | 9 + .../data/rest/test/mvc/Profile.java | 80 ++ .../data/rest/test/mvc/ProfileRepository.java | 9 + .../test/resouces/META-INF/persistence.xml | 16 + .../spring-data-rest/repositories-export.xml | 18 + .../META-INF/spring-data-rest/shared.xml | 29 + rest/src/test/resouces/logback.xml | 19 + settings.gradle | 3 + 43 files changed, 3379 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 core/build.gradle create mode 100644 core/src/main/java/org/springframework/data/rest/core/Handler.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/Link.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/SimpleLink.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/util/BeanUtils.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/util/FluentBeanUtils.java create mode 100644 core/src/main/java/org/springframework/data/rest/core/util/UriUtils.java create mode 100644 core/src/test/groovy/org/springframework/data/rest/core/UriUtilsSpec.groovy create mode 100644 core/src/test/resources/logback.xml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 repository/build.gradle create mode 100644 repository/src/main/java/org/springframework/data/rest/repository/JpaEntityMetadata.java create mode 100644 repository/src/main/java/org/springframework/data/rest/repository/JpaRepositoryMetadata.java create mode 100644 rest/build.gradle create mode 100644 rest/src/main/java/org/springframework/data/rest/mvc/JsonView.java create mode 100644 rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestConfiguration.java create mode 100644 rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestController.java create mode 100644 rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestMvcConfiguration.java create mode 100644 rest/src/main/java/org/springframework/data/rest/mvc/ServerHttpRequestMethodArgumentResolver.java create mode 100644 rest/src/main/java/org/springframework/data/rest/mvc/UriListView.java create mode 100644 rest/src/main/webapp/WEB-INF/repositories.xml create mode 100644 rest/src/main/webapp/WEB-INF/web.xml create mode 100644 rest/src/test/java/org/springframework/data/rest/test/RestBuilder.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/Address.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/AddressRepository.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/Person.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/PersonLoader.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/PersonRepository.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/Profile.java create mode 100644 rest/src/test/java/org/springframework/data/rest/test/mvc/ProfileRepository.java create mode 100644 rest/src/test/resouces/META-INF/persistence.xml create mode 100644 rest/src/test/resouces/META-INF/spring-data-rest/repositories-export.xml create mode 100644 rest/src/test/resouces/META-INF/spring-data-rest/shared.xml create mode 100644 rest/src/test/resouces/logback.xml create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..23650c61f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.gradle/ +build/ +*.i* \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..959ad4e53 --- /dev/null +++ b/build.gradle @@ -0,0 +1,76 @@ +apply plugin: "base" + +allprojects { + apply plugin: "idea" + apply plugin: "maven" + + group = "org.springframework.data.rest" + version = "$sdRestVersion" + + releaseBuild = version.endsWith("RELEASE") + snapshotBuild = version.endsWith("SNAPSHOT") + + sourceCompatibility = 6 + targetCompatibility = 6 + + configurations.all { + exclude group: "commons-logging" + exclude module: "slf4j-log4j12" + exclude module: "groovy-all", version: "1.8.0-beta-3-SNAPSHOT" + } + +} + +subprojects { + + repositories { + mavenCentral(artifactUrls: [ + "http://maven.springframework.org/release", + "http://maven.springframework.org/milestone", + //"http://repository.jboss.org/maven2/", + //"http://download.java.net/maven/2/" + ]) + mavenLocal() + } + + apply plugin: "java" + apply plugin: "groovy" + + [compileJava, compileTestJava]*.options*.compilerArgs = ["-Xlint:-serial", "-Xlint:-unchecked"] + + dependencies { + groovy "org.codehaus.groovy:groovy:$groovyVersion" + + // Logging + compile "org.slf4j:slf4j-api:$slf4jVersion" + runtime "org.slf4j:jcl-over-slf4j:$slf4jVersion" + runtime "ch.qos.logback:logback-classic:$logbackVersion" + + // Jackson JSON + compile "org.codehaus.jackson:jackson-mapper-asl:$jacksonVersion" + + // Spring + compile "org.springframework:spring-beans:$springVersion" + compile "org.springframework:spring-context:$springVersion" + compile "org.springframework:spring-core:$springVersion" + compile "org.springframework:spring-web:$springVersion" + + // Testing + testCompile "org.spockframework:spock-core:$spockVersion" + testCompile "org.spockframework:spock-spring:$spockVersion" + testCompile "org.hamcrest:hamcrest-all:1.1" + testCompile "org.springframework:spring-test:$springVersion" + testRuntime "org.springframework:spring-context-support:$springVersion" + + } + +} + +task wrapper(type: Wrapper) { gradleVersion = "1.0-milestone-8" } + +idea { + project.ipr.withXml { provider -> + provider.node.component.find { it.@name == 'VcsDirectoryMappings' }.mapping.@vcs = 'Git' + } + module.jdkName = "OpenJDK 1.7" +} diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 000000000..1b01ab021 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,6 @@ +dependencies { + + // Google Guava + compile "com.google.guava:guava:11.0.1" + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/Handler.java b/core/src/main/java/org/springframework/data/rest/core/Handler.java new file mode 100644 index 000000000..e0466fab1 --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/Handler.java @@ -0,0 +1,8 @@ +package org.springframework.data.rest.core; + +/** + * @author Jon Brisbin + */ +public interface Handler { + V handle(T t); +} diff --git a/core/src/main/java/org/springframework/data/rest/core/Link.java b/core/src/main/java/org/springframework/data/rest/core/Link.java new file mode 100644 index 000000000..c22dd684c --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/Link.java @@ -0,0 +1,14 @@ +package org.springframework.data.rest.core; + +import java.net.URI; + +/** + * @author Jon Brisbin + */ +public interface Link { + + String rel(); + + URI href(); + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/SimpleLink.java b/core/src/main/java/org/springframework/data/rest/core/SimpleLink.java new file mode 100644 index 000000000..0baf9f3d3 --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/SimpleLink.java @@ -0,0 +1,33 @@ +package org.springframework.data.rest.core; + +import java.net.URI; + +/** + * @author Jon Brisbin + */ +public class SimpleLink implements Link { + + private String rel; + private URI href; + + public SimpleLink(String rel, URI href) { + this.rel = rel; + this.href = href; + } + + @Override public String rel() { + return rel; + } + + @Override public URI href() { + return href; + } + + @Override public String toString() { + return "SimpleLink{" + + "rel='" + rel + '\'' + + ", href=" + href + + '}'; + } + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/util/BeanUtils.java b/core/src/main/java/org/springframework/data/rest/core/util/BeanUtils.java new file mode 100644 index 000000000..547a0a40f --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/util/BeanUtils.java @@ -0,0 +1,211 @@ +package org.springframework.data.rest.core.util; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.UncheckedExecutionException; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * @author Jon Brisbin + */ +public abstract class BeanUtils { + + private BeanUtils() { + } + + public static ConfigurableConversionService CONVERSION_SERVICE = new DefaultConversionService(); + + private static final LoadingCache fields = CacheBuilder.newBuilder().build( + new CacheLoader() { + @Override public Field load(Object[] key) throws Exception { + Class clazz = (Class) key[0]; + String name = (String) key[1]; + Field f = ReflectionUtils.findField(clazz, name); + if (null != f) { + ReflectionUtils.makeAccessible(f); + return f; + } else { + throw new IllegalArgumentException("Field " + clazz.getName() + "." + name + " not found"); + } + } + } + ); + private static final LoadingCache methods = CacheBuilder.newBuilder().build( + new CacheLoader() { + @Override public Method load(Object[] key) throws Exception { + Class clazz = (Class) key[0]; + String name = (String) key[1]; + Integer paramCnt = key.length == 3 ? (Integer) key[2] : 0; + + for (Method m : clazz.getDeclaredMethods()) { + if (m.getName().equals(name)) { + if (m.getParameterTypes().length == paramCnt) { + ReflectionUtils.makeAccessible(m); + return m; + } + } + } + + throw new IllegalArgumentException("Method " + clazz.getName() + "." + name + " not found"); + } + } + ); + + public static boolean hasProperty(String property, Object... objs) { + for (Object obj : objs) { + if (obj instanceof Map) { + return ((Map) obj).containsKey(property); + } + Class type = obj.getClass(); + try { + if (FluentBeanUtils.isFluentBean(type)) { + return null != methods.get(new Object[]{type, property}); + } else { + if (null == methods.get(new Object[]{type, "get" + StringUtils.capitalize(property)})) { + return null != fields.get(new Object[]{type, property}); + } else { + return true; + } + } + } catch (UncheckedExecutionException e) { + if (e.getCause().getClass() == IllegalArgumentException.class) { + return false; + } else { + throw new IllegalStateException(e); + } + } catch (ExecutionException e) { + throw new IllegalStateException(e); + } + } + return false; + } + + @SuppressWarnings({"unchecked"}) + public static T findFirst(Class clazz, List stack) { + for (Object o : stack) { + if (ClassUtils.isAssignable(clazz, o.getClass())) { + return (T) o; + } + } + return null; + } + + @SuppressWarnings({"unchecked"}) + public static Object findFirst(Object o, Object... objs) { + for (Object obj : objs) { + if (o == obj || null != o && o.equals(obj)) { + return obj; + } else if (obj instanceof List) { + return Collections.binarySearch((List) obj, o); + } else if (obj instanceof Object[]) { + return Arrays.binarySearch((Object[]) obj, o); + } + } + return null; + } + + public static Object findFirst(String property, Object... objs) { + for (Object obj : objs) { + if (obj instanceof Map) { + return ((Map) obj).get(property); + } + Class type = obj.getClass(); + try { + Field f = fields.get(new Object[]{type, property}); + if (FluentBeanUtils.isFluentBean(type)) { + return FluentBeanUtils.get(property, obj); + } else { + Method getter = methods.get(new Object[]{type, "get" + StringUtils.capitalize(property)}); + try { + if (null != getter) { + return getter.invoke(obj); + } else { + return f.get(obj); + } + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } catch (InvocationTargetException e) { + throw new IllegalStateException(e); + } + } + } catch (IllegalArgumentException e) { + } catch (ExecutionException e) { + throw new IllegalArgumentException(e); + } + } + + return null; + } + + public static boolean containsType(Class type, List objs) { + return containsType(type, objs.toArray()); + } + + public static boolean containsType(Class type, Object[] objs) { + for (Object obj : objs) { + if (null != obj && ClassUtils.isAssignable(obj.getClass(), type)) { + return true; + } + } + return false; + } + + @SuppressWarnings({"unchecked"}) + public static Object invoke(String methodName, Object target, Object... args) { + return invoke(methodName, target, Object.class, args); + } + + @SuppressWarnings({"unchecked"}) + public static T invoke(String methodName, Object target, Class returnType, Object... args) { + if (null == target) { + return null; + } + + Class type = target.getClass(); + try { + Method m = methods.get(new Object[]{type, methodName, args.length}); + List newArgs = new ArrayList(args.length); + Class[] paramTypes = m.getParameterTypes(); + for (int i = 0; i < args.length; i++) { + Object o = args[i]; + Class oType = o.getClass(); + Class pType = paramTypes[i]; + if (!ClassUtils.isAssignable(oType, pType)) { + newArgs.add(CONVERSION_SERVICE.convert(o, pType)); + } else { + newArgs.add(o); + } + } + + Object rtnVal = m.invoke(target, newArgs.toArray()); + if ((returnType != Void.TYPE || returnType != Object.class) + && null != rtnVal + && !ClassUtils.isAssignable(returnType, rtnVal.getClass())) { + return CONVERSION_SERVICE.convert(rtnVal, returnType); + } else { + return (T) rtnVal; + } + } catch (IllegalArgumentException e) { + } catch (Exception e) { + throw new IllegalStateException(e); + } + + return null; + } + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java b/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java new file mode 100644 index 000000000..ac8083859 --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java @@ -0,0 +1,89 @@ +package org.springframework.data.rest.core.util; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import org.codehaus.jackson.JsonParser; +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.JsonToken; +import org.codehaus.jackson.map.DeserializationContext; +import org.codehaus.jackson.map.deser.std.StdDeserializer; +import org.springframework.core.convert.ConversionService; +import org.springframework.util.ClassUtils; + +/** + * @author Jon Brisbin + */ +public class FluentBeanDeserializer extends StdDeserializer { + + private ConversionService conversionService; + private FluentBeanUtils.Metadata beanMeta; + + @SuppressWarnings({"unchecked"}) + public FluentBeanDeserializer(final Class valueClass, ConversionService conversionService) { + super(valueClass); + this.conversionService = conversionService; + this.beanMeta = FluentBeanUtils.metadata(valueClass); + + if (!FluentBeanUtils.isFluentBean(valueClass)) { + throw new IllegalArgumentException("Class of type " + valueClass + " is not a FluentBean"); + } + } + + @Override + public Object deserialize(JsonParser jp, + DeserializationContext ctxt) + throws IOException, + JsonProcessingException { + if (jp.getCurrentToken() != JsonToken.START_OBJECT) { + throw ctxt.mappingException(_valueClass); + } + + Object bean; + try { + bean = _valueClass.newInstance(); + } catch (InstantiationException e) { + throw new IllegalStateException(e); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + + while (jp.nextToken() != JsonToken.END_OBJECT) { + String name = jp.getCurrentName(); + Method setter = beanMeta.setters().get(name); + + Object obj; + if (null != setter) { + Class targetType = setter.getParameterTypes()[0]; + if (ClassUtils.isAssignable(targetType, Long.class)) { + obj = jp.nextLongValue(-1); + } else if (ClassUtils.isAssignable(targetType, Integer.class)) { + obj = jp.nextIntValue(-1); + } else if (ClassUtils.isAssignable(targetType, Boolean.class)) { + obj = jp.nextBooleanValue(); + } else { + obj = jp.nextTextValue(); + } + + if (null != obj) { + if (!ClassUtils.isAssignable(obj.getClass(), targetType)) { + obj = conversionService.convert(obj, targetType); + } + + try { + setter.invoke(bean, obj); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } catch (InvocationTargetException e) { + throw new IllegalStateException(e); + } + } + } + + } + + return bean; + } + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java b/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java new file mode 100644 index 000000000..ce2130e64 --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java @@ -0,0 +1,75 @@ +package org.springframework.data.rest.core.util; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.codehaus.jackson.JsonGenerationException; +import org.codehaus.jackson.JsonGenerator; +import org.codehaus.jackson.map.SerializerProvider; +import org.codehaus.jackson.map.ser.std.SerializerBase; +import org.springframework.util.ClassUtils; + +/** + * @author Jon Brisbin + */ +public class FluentBeanSerializer extends SerializerBase { + + @SuppressWarnings({"unchecked"}) + public FluentBeanSerializer(final Class t) { + super(t); + + if (!FluentBeanUtils.isFluentBean(t)) { + throw new IllegalArgumentException("Class of type " + t + " is not a FluentBean"); + } + } + + @SuppressWarnings({"unchecked"}) + @Override + public void serialize(final Object value, + final JsonGenerator jgen, + final SerializerProvider provider) + throws IOException, + JsonGenerationException { + if (null == value) { + provider.defaultSerializeNull(jgen); + } else { + Class type = value.getClass(); + if (ClassUtils.isAssignable(type, Collection.class)) { + jgen.writeStartArray(); + for (Object o : (Collection) value) { + write(o, jgen, provider); + } + jgen.writeEndArray(); + } else if (ClassUtils.isAssignable(type, Map.class)) { + jgen.writeStartObject(); + for (Map.Entry entry : ((Map) value).entrySet()) { + jgen.writeFieldName(entry.getKey()); + write(entry.getValue(), jgen, provider); + } + jgen.writeEndObject(); + } else { + write(value, jgen, provider); + } + } + } + + private void write(final Object value, + final JsonGenerator jgen, + final SerializerProvider provider) throws IOException { + Class type = value.getClass(); + if (ClassUtils.isAssignable(type, _handledType)) { + jgen.writeStartObject(); + for (String fname : FluentBeanUtils.metadata(type).fieldNames()) { + jgen.writeFieldName(fname); + write(FluentBeanUtils.get(fname, value), jgen, provider); + } + jgen.writeEndObject(); + } else { + jgen.writeObject(value); + } + } + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanUtils.java b/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanUtils.java new file mode 100644 index 000000000..075304fd8 --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/util/FluentBeanUtils.java @@ -0,0 +1,132 @@ +package org.springframework.data.rest.core.util; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.ReflectionUtils; + +/** + * @author Jon Brisbin + */ +public abstract class FluentBeanUtils { + + private static final Logger log = LoggerFactory.getLogger(FluentBeanUtils.class); + private static final LoadingCache, Metadata> metadata = CacheBuilder.newBuilder().build( + new CacheLoader, Metadata>() { + @Override public Metadata load(Class type) throws Exception { + final Metadata meta = new Metadata(); + ReflectionUtils.doWithFields( + type, + new ReflectionUtils.FieldCallback() { + @Override public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException { + final String fname = field.getName(); + if (!fname.startsWith("_")) { + ReflectionUtils.doWithMethods(field.getDeclaringClass(), new ReflectionUtils.MethodCallback() { + @Override + public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException { + if (method.getName().equals(fname)) { + if (method.getParameterTypes().length == 0) { + meta.getters.put(fname, method); + } else if (method.getParameterTypes().length == 1) { + meta.setters.put(fname, method); + } + meta.fieldNames.add(fname); + } + } + }); + } + } + } + ); + return meta; + } + } + ); + + public static Metadata metadata(Class targetType) { + try { + return metadata.get(targetType); + } catch (ExecutionException e) { + throw new IllegalStateException(e); + } + } + + public static Object set(String property, Object value, Object bean) { + if (null == bean) { + return null; + } + + Class type = bean.getClass(); + try { + Method setter = metadata.get(type).setters.get(property); + if (null != setter) { + return setter.invoke(bean, value); + } else { + return null; + } + } catch (Throwable t) { + if (log.isDebugEnabled()) { + log.debug(t.getMessage(), t); + } + return null; + } + } + + public static Object get(String property, Object bean) { + if (null == bean) { + return null; + } + + Class type = bean.getClass(); + try { + Method getter = metadata.get(type).getters.get(property); + if (null != getter) { + return getter.invoke(bean); + } else { + return null; + } + } catch (Throwable t) { + if (log.isDebugEnabled()) { + log.debug(t.getMessage(), t); + } + return null; + } + } + + public static boolean isFluentBean(Class type) { + try { + return metadata.get(type).getters.size() > 0; + } catch (ExecutionException e) { + throw new IllegalStateException(e); + } + } + + public static class Metadata { + List fieldNames = new ArrayList(); + Map getters = new HashMap(); + Map setters = new HashMap(); + + public List fieldNames() { + return fieldNames; + } + + public Map getters() { + return getters; + } + + public Map setters() { + return setters; + } + } + +} diff --git a/core/src/main/java/org/springframework/data/rest/core/util/UriUtils.java b/core/src/main/java/org/springframework/data/rest/core/util/UriUtils.java new file mode 100644 index 000000000..0be37378a --- /dev/null +++ b/core/src/main/java/org/springframework/data/rest/core/util/UriUtils.java @@ -0,0 +1,118 @@ +package org.springframework.data.rest.core.util; + +import java.net.URI; +import java.util.List; +import java.util.Stack; + +import org.springframework.data.rest.core.Handler; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * @author Jon Brisbin + */ +public abstract class UriUtils { + + private UriUtils() { + } + + public static boolean validBaseUri(URI baseUri, URI uri) { + String path = UriUtils.path(baseUri.relativize(uri)); + return !StringUtils.hasText(path) || path.charAt(0) != '/'; + } + + public static V foreach(URI baseUri, URI uri, Handler handler) { + List uris = explode(baseUri, uri); + V v = null; + for (URI u : uris) { + v = handler.handle(u); + } + return v; + } + + public static Stack explode(URI baseUri, URI uri) { + Stack uris = new Stack(); + if (StringUtils.hasText(uri.getPath())) { + URI relativeUri = baseUri.relativize(uri); + if (StringUtils.hasText(relativeUri.getPath())) { + for (String part : relativeUri.getPath().split("/")) { + uris.add(URI.create(part + (StringUtils.hasText(uri.getQuery()) ? "?" + uri.getQuery() : ""))); + } + } + } + return uris; + } + + public static URI merge(URI baseUri, URI... uris) { + StringBuilder query = new StringBuilder(); + + UriComponentsBuilder ub = UriComponentsBuilder.fromUri(baseUri); + for (URI uri : uris) { + String s = uri.getScheme(); + if (null != s) { + ub.scheme(s); + } + + s = uri.getUserInfo(); + if (null != s) { + ub.userInfo(s); + } + + s = uri.getHost(); + if (null != s) { + ub.host(s); + } + + int i = uri.getPort(); + if (i > 0) { + ub.port(i); + } + + s = uri.getPath(); + if (null != s) { + if (!uri.isAbsolute() && StringUtils.hasText(s)) { + ub.pathSegment(s); + } else { + ub.path(s); + } + } + + s = uri.getQuery(); + if (null != s) { + if (query.length() > 0) { + query.append("&"); + } + query.append(s); + } + + s = uri.getFragment(); + if (null != s) { + ub.fragment(s); + } + } + + if (query.length() > 0) { + ub.query(query.toString()); + } + + return ub.build().toUri(); + } + + public static String path(URI uri) { + if (null == uri) { + return null; + } + String s = uri.getPath(); + if (s.endsWith("/")) { + return s.substring(0, s.length() - 1); + } else { + return s; + } + } + + public static URI tail(URI baseUri, URI uri) { + Stack uris = explode(baseUri, uri); + return uris.size() > 0 ? uris.get(Math.max(uris.size() - 1, 0)) : null; + } + +} diff --git a/core/src/test/groovy/org/springframework/data/rest/core/UriUtilsSpec.groovy b/core/src/test/groovy/org/springframework/data/rest/core/UriUtilsSpec.groovy new file mode 100644 index 000000000..60910deb8 --- /dev/null +++ b/core/src/test/groovy/org/springframework/data/rest/core/UriUtilsSpec.groovy @@ -0,0 +1,48 @@ +package org.springframework.data.rest.core + +import org.springframework.data.rest.core.util.UriUtils +import spock.lang.Specification + +/** + * @author Jon Brisbin + */ +class UriUtilsSpec extends Specification { + + def "merges URIs correctly"() { + + given: + // (absolute) URI of the base resource + def baseUri = new URI("http://localhost:8080/baseUrl") + // (relative) URI of the top-level Resource + def uri2 = new URI("resource") + // (relative) URI of the second-level Resource + def uri3 = new URI("1") + // (fragment) URI of the bottom-level Resource + def uri4 = new URI("count") + + when: + def uri5 = UriUtils.merge(baseUri, uri2, uri3, uri4) + + then: + uri5.toString() == "http://localhost:8080/baseUrl/resource/1/count" + + } + + def "explodes URIs correctly"() { + + given: + // (absolute) URI of the base resource + def baseUri = new URI("http://localhost:8080/baseUrl") + // (absolute) URI of the full resource to get a path to + def resourceUri = new URI("http://localhost:8080/baseUrl/resource/1/property") + + when: + def uris = UriUtils.explode(baseUri, resourceUri) + + then: + uris.size() == 3 + uris[2].path == "property" + + } + +} diff --git a/core/src/test/resources/logback.xml b/core/src/test/resources/logback.xml new file mode 100644 index 000000000..919ca3f93 --- /dev/null +++ b/core/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..d6c71179f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# Logging +slf4jVersion = 1.6.4 +logbackVersion = 1.0.0 + +# Libraries +springVersion = 3.1.1.RELEASE +cglibVersion = 2.2 + +# Languages +groovyVersion = 1.8.6 + +# Supporting libraries +sdCommonsVersion = 1.2.0.RELEASE +sdJpaVersion = 1.0.1.RELEASE +jacksonVersion = 1.9.2 +hibernateVersion = 3.5.6-Final + +# Testing +spockVersion = 0.5-groovy-1.8 + +## OSGi ranges +spring.range = "[3.1.1, 4.0.0)" +jackson.range = "[1.9, 2.0.0)" + +sdRestVersion = 1.0.0.BUILD-SNAPSHOT \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1182d05846aeb899b0272596c2952e1cb22e59b6 GIT binary patch literal 41664 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 \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" +APP_HOME="`pwd -P`" +cd "$SAVED" + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query businessSystem maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + JAVA_OPTS="$JAVA_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..aec99730b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/repository/build.gradle b/repository/build.gradle new file mode 100644 index 000000000..4b8a8c85a --- /dev/null +++ b/repository/build.gradle @@ -0,0 +1,22 @@ +dependencies { + + // Spring + compile "org.springframework:spring-orm:$springVersion" + compile "org.springframework:spring-oxm:$springVersion" + compile "org.springframework:spring-tx:$springVersion" + + // JPA + compile "org.hibernate.javax.persistence:hibernate-jpa-2.0-api:1.0.1.Final" + + // Spring Data + compile "org.springframework.data:spring-data-commons-core:$sdCommonsVersion" + compile "org.springframework.data:spring-data-jpa:$sdJpaVersion" + + // Exporter core + compile project(":core") + + // Testing + testCompile "org.hibernate:hibernate-entitymanager:$hibernateVersion" + testCompile "org.hsqldb:hsqldb:1.8.0.10" + +} diff --git a/repository/src/main/java/org/springframework/data/rest/repository/JpaEntityMetadata.java b/repository/src/main/java/org/springframework/data/rest/repository/JpaEntityMetadata.java new file mode 100644 index 000000000..0fe82f32b --- /dev/null +++ b/repository/src/main/java/org/springframework/data/rest/repository/JpaEntityMetadata.java @@ -0,0 +1,145 @@ +package org.springframework.data.rest.repository; + +import java.io.Serializable; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import javax.persistence.metamodel.Attribute; +import javax.persistence.metamodel.EntityType; +import javax.persistence.metamodel.PluralAttribute; +import javax.persistence.metamodel.SingularAttribute; + +import org.springframework.data.rest.core.Handler; +import org.springframework.util.ReflectionUtils; + +/** + * @author Jon Brisbin + */ +public class JpaEntityMetadata { + + private Class targetType; + private Map embeddedAttributes = new HashMap(); + private Map fields = new HashMap(); + private Map linkedAttributes = new HashMap(); + private final Attribute idAttribute; + private final Attribute versionAttribute; + + @SuppressWarnings({"unchecked"}) + public JpaEntityMetadata(EntityType entityType, JpaRepositoryMetadata repositoryMetadata) { + targetType = entityType.getJavaType(); + + Attribute idAttribute = entityType.getId(entityType.getIdType().getJavaType()); + Attribute versionAttribute = entityType.getVersion(Long.class); + for (Attribute attr : (Set) entityType.getAttributes()) { + String name = attr.getName(); + Field f = ReflectionUtils.findField(targetType, attr.getName()); + ReflectionUtils.makeAccessible(f); + fields.put(name, f); + + if (attr instanceof SingularAttribute) { + SingularAttribute sattr = (SingularAttribute) attr; + if (null != repositoryMetadata.repositoryFor(attr.getJavaType())) { + linkedAttributes.put(name, attr); + } else if (!sattr.isId() && !sattr.isVersion()) { + embeddedAttributes.put(name, attr); + } + } else if (attr instanceof PluralAttribute) { + PluralAttribute pattr = (PluralAttribute) attr; + if (pattr.getElementType() instanceof EntityType + && null != repositoryMetadata.repositoryFor(pattr.getElementType().getJavaType())) { + linkedAttributes.put(name, attr); + } else { + embeddedAttributes.put(name, attr); + } + } + } + + this.idAttribute = idAttribute; + this.versionAttribute = versionAttribute; + } + + public Class targetType() { + return targetType; + } + + public Map embeddedAttributes() { + return Collections.unmodifiableMap(embeddedAttributes); + } + + public Map linkedAttributes() { + return Collections.unmodifiableMap(linkedAttributes); + } + + public Attribute idAttribute() { + return idAttribute; + } + + public Attribute versionAttribute() { + return versionAttribute; + } + + public void id(Serializable id, Object target) { + set(idAttribute.getName(), id, target); + } + + public Object id(Object target) { + return get(idAttribute.getName(), target); + } + + public Object version(Object target) { + return (null != versionAttribute ? get(versionAttribute.getName(), target) : null); + } + + public V doWithEmbedded(Handler handler) { + if (null == handler) { + return null; + } + V v = null; + for (Attribute attr : embeddedAttributes.values()) { + v = handler.handle(attr); + } + return v; + } + + public V doWithLinked(String name, Handler handler) { + if (null == handler) { + return null; + } + V v = null; + Attribute attr = linkedAttributes.get(name); + if (null != attr) { + v = handler.handle(attr); + } + return v; + } + + public V doWithLinked(Handler handler) { + if (null == handler) { + return null; + } + V v = null; + for (Attribute attr : linkedAttributes.values()) { + v = handler.handle(attr); + } + return v; + } + + public Object get(String name, Object target) { + try { + return fields.get(name).get(target); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + + public void set(String name, Object arg, Object target) { + try { + fields.get(name).set(target, arg); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/repository/src/main/java/org/springframework/data/rest/repository/JpaRepositoryMetadata.java b/repository/src/main/java/org/springframework/data/rest/repository/JpaRepositoryMetadata.java new file mode 100644 index 000000000..4d797ffb5 --- /dev/null +++ b/repository/src/main/java/org/springframework/data/rest/repository/JpaRepositoryMetadata.java @@ -0,0 +1,166 @@ +package org.springframework.data.rest.repository; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.metamodel.EntityType; +import javax.persistence.metamodel.Metamodel; + +import org.springframework.aop.support.AopUtils; +import org.springframework.aop.target.SingletonTargetSource; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.EntityInformation; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * @author Jon Brisbin + */ +public class JpaRepositoryMetadata implements InitializingBean, ApplicationContextAware { + + private ApplicationContext applicationContext; + private Map, RepositoryCacheEntry> repositories = new HashMap, RepositoryCacheEntry>(); + private EntityManager entityManager; + private Metamodel metamodel; + + @PersistenceContext + public void setEntityManager(EntityManager entityManager) { + this.entityManager = entityManager; + this.metamodel = entityManager.getMetamodel(); + } + + public CrudRepository repositoryFor(String name) { + if (null != name) { + for (Map.Entry, RepositoryCacheEntry> entry : repositories.entrySet()) { + if (name.equals(repositoryNameFor(entry.getValue().repository))) { + return entry.getValue().repository; + } + } + } + return null; + } + + public CrudRepository repositoryFor(Class domainClass) { + RepositoryCacheEntry entry = repositories.get(domainClass); + if (null != entry) { + return entry.repository; + } + return null; + } + + public EntityInformation entityInfoFor(Class domainClass) { + RepositoryCacheEntry entry = repositories.get(domainClass); + if (null != entry) { + return entry.entityInfo; + } + return null; + } + + public EntityType entityTypeFor(Class domainClass) { + return metamodel.entity(domainClass); + } + + public EntityInformation entityInfoFor(CrudRepository repository) { + for (Map.Entry, RepositoryCacheEntry> entry : repositories.entrySet()) { + if (entry.getValue().repository == repository) { + return entry.getValue().entityInfo; + } + } + return null; + } + + public JpaEntityMetadata entityMetadataFor(Class domainClass) { + RepositoryCacheEntry entry = repositories.get(domainClass); + if (null == entry.entityMetadata) { + entry.entityMetadata = new JpaEntityMetadata(metamodel.entity(domainClass), this); + } + return entry.entityMetadata; + } + + public String repositoryNameFor(Class domainClass) { + RepositoryCacheEntry entry = repositories.get(domainClass); + if (null != entry) { + return entry.name; + } + return null; + } + + public String repositoryNameFor(CrudRepository repository) { + for (Map.Entry, RepositoryCacheEntry> entry : repositories.entrySet()) { + if (entry.getValue().repository == repository) { + return entry.getValue().name; + } + } + return null; + } + + @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + public List repositoryNames() { + List names = new ArrayList(); + for (Map.Entry, RepositoryCacheEntry> entry : repositories.entrySet()) { + names.add(entry.getValue().name); + } + return names; + } + + public void setRepositories(Collection repositories) { + for (CrudRepository repository : repositories) { + Class repoClass = AopUtils.getTargetClass(repository); + Field infoField = ReflectionUtils.findField(repoClass, "entityInformation"); + ReflectionUtils.makeAccessible(infoField); + Method m = ReflectionUtils.findMethod(repository.getClass(), "getTargetSource"); + ReflectionUtils.makeAccessible(m); + try { + SingletonTargetSource targetRepo = (SingletonTargetSource) m.invoke(repository); + EntityInformation entityInfo = (EntityInformation) infoField.get(targetRepo.getTarget()); + Class[] intfs = repository.getClass().getInterfaces(); + String name = StringUtils.uncapitalize(intfs[0].getSimpleName().replaceAll("Repository", "")); + this.repositories.put(entityInfo.getJavaType(), new RepositoryCacheEntry(name, repository, entityInfo, null)); + } catch (Throwable t) { + throw new IllegalStateException(t); + } + } + } + + @Override public void afterPropertiesSet() throws Exception { + if (this.repositories.isEmpty()) { + ApplicationContext appCtx = applicationContext; + while (null != appCtx) { + Map beans = appCtx.getBeansOfType(CrudRepository.class); + setRepositories(beans.values()); + appCtx = appCtx.getParent(); + } + } + } + + private class RepositoryCacheEntry { + String name; + CrudRepository repository; + EntityInformation entityInfo; + JpaEntityMetadata entityMetadata; + + private RepositoryCacheEntry(String name, + CrudRepository repository, + EntityInformation entityInfo, + JpaEntityMetadata entityMetadata) { + this.name = name; + this.repository = repository; + this.entityInfo = entityInfo; + this.entityMetadata = entityMetadata; + } + } + +} diff --git a/rest/build.gradle b/rest/build.gradle new file mode 100644 index 000000000..afc9cf583 --- /dev/null +++ b/rest/build.gradle @@ -0,0 +1,22 @@ +apply plugin: "war" +apply plugin: "jetty" + +dependencies { + + // APIS + compile "javax.servlet:servlet-api:2.5" + + // JPA + compile "org.hibernate.javax.persistence:hibernate-jpa-2.0-api:1.0.1.Final" + compile "org.hibernate:hibernate-entitymanager:$hibernateVersion" + + // H2 + compile "org.hsqldb:hsqldb:1.8.0.10" + + // Spring + compile "org.springframework:spring-webmvc:$springVersion" + + // Repository Exporter support + compile project(":repository") + +} \ No newline at end of file diff --git a/rest/src/main/java/org/springframework/data/rest/mvc/JsonView.java b/rest/src/main/java/org/springframework/data/rest/mvc/JsonView.java new file mode 100644 index 000000000..bbe4a4400 --- /dev/null +++ b/rest/src/main/java/org/springframework/data/rest/mvc/JsonView.java @@ -0,0 +1,84 @@ +package org.springframework.data.rest.mvc; + +import java.io.ByteArrayOutputStream; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.codehaus.jackson.map.ObjectMapper; +import org.codehaus.jackson.map.ser.CustomSerializerFactory; +import org.springframework.data.rest.core.SimpleLink; +import org.springframework.data.rest.core.util.FluentBeanSerializer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.view.AbstractView; + +/** + * @author Jon Brisbin + */ +@SuppressWarnings({"unchecked"}) +public class JsonView extends AbstractView { + + private ObjectMapper mapper = new ObjectMapper(); + + { + CustomSerializerFactory customSerializerFactory = new CustomSerializerFactory(); + customSerializerFactory.addSpecificMapping(SimpleLink.class, new FluentBeanSerializer(SimpleLink.class)); + mapper.setSerializerFactory(customSerializerFactory); + } + + public JsonView(String mediaType) { + setContentType(mediaType); + } + + @Override + protected void renderMergedOutputModel(Map model, + HttpServletRequest request, + HttpServletResponse response) throws Exception { + HttpStatus status = status(model); + response.setStatus(status.value()); + + String contentType = getContentType(); + HttpHeaders headers = headers(model); + if (null != headers) { + for (Map.Entry entry : headers.toSingleValueMap().entrySet()) { + response.setHeader(entry.getKey(), entry.getValue()); + } + if (null != headers.getContentType()) { + contentType = headers.getContentType().toString(); + } + } + response.setContentType(contentType); + + Object resource = model.get("resource"); + if (null != resource) { + if (resource instanceof Throwable) { + resource = ((Throwable) resource).getMessage(); + } + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + mapper.writerWithDefaultPrettyPrinter().writeValue(bout, resource); + + response.getOutputStream().write(bout.toByteArray()); + } else { + response.setContentLength(0); + } + } + + + private HttpStatus status(Map model) { + Object o = model.get("status"); + if (null != o && o instanceof HttpStatus) { + return (HttpStatus) o; + } + throw new IllegalArgumentException("No status is set in the model."); + } + + private HttpHeaders headers(Map model) { + Object o = model.get("headers"); + if (null != o && o instanceof HttpHeaders) { + return (HttpHeaders) o; + } + return null; + } + +} diff --git a/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestConfiguration.java b/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestConfiguration.java new file mode 100644 index 000000000..1f5672703 --- /dev/null +++ b/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestConfiguration.java @@ -0,0 +1,61 @@ +package org.springframework.data.rest.mvc; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportResource; +import org.springframework.data.rest.repository.JpaRepositoryMetadata; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJacksonHttpMessageConverter; +import org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor; + +/** + * @author Jon Brisbin + */ +@Configuration +@ImportResource("classpath*:META-INF/spring-data-rest/**/*-export.xml") +public class RepositoryRestConfiguration { + + @Autowired(required = false) + URI baseUri; + @Autowired(required = false) + JpaRepositoryMetadata jpaRepositoryMetadata; + @Autowired(required = false) + List> httpMessageConverters = new ArrayList>(); + + @Bean URI baseUri() { + if (null == baseUri) { + baseUri = URI.create(""); + } + return baseUri; + } + + @Bean List> httpMessageConverters() { + if (httpMessageConverters.isEmpty()) { + MappingJacksonHttpMessageConverter json = new MappingJacksonHttpMessageConverter(); + json.setSupportedMediaTypes( + Arrays.asList(MediaType.APPLICATION_JSON, MediaType.valueOf("application/x-spring-data+json")) + ); + httpMessageConverters.add(json); + } + return httpMessageConverters; + } + + @Bean JpaRepositoryMetadata jpaRepositoryMetadata() throws Exception { + if (null == jpaRepositoryMetadata) { + jpaRepositoryMetadata = new JpaRepositoryMetadata(); + } + return jpaRepositoryMetadata; + } + + @Bean PersistenceAnnotationBeanPostProcessor persistenceAnnotationBeanPostProcessor() { + return new PersistenceAnnotationBeanPostProcessor(); + } + +} diff --git a/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestController.java b/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestController.java new file mode 100644 index 000000000..4633b112b --- /dev/null +++ b/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestController.java @@ -0,0 +1,908 @@ +package org.springframework.data.rest.mvc; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Serializable; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Stack; +import javax.persistence.metamodel.Attribute; +import javax.persistence.metamodel.EntityType; +import javax.persistence.metamodel.PluralAttribute; +import javax.persistence.metamodel.SingularAttribute; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.EntityInformation; +import org.springframework.data.rest.core.Handler; +import org.springframework.data.rest.core.Link; +import org.springframework.data.rest.core.SimpleLink; +import org.springframework.data.rest.core.util.UriUtils; +import org.springframework.data.rest.repository.JpaEntityMetadata; +import org.springframework.data.rest.repository.JpaRepositoryMetadata; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * @author Jon Brisbin + */ +@Controller +public class RepositoryRestController implements InitializingBean { + + public static final String STATUS = "status"; + public static final String HEADERS = "headers"; + public static final String RESOURCE = "resource"; + public static final String SELF = "self"; + public static final String LINKS = "_links"; + + public static final int HAS_RESOURCE = 1; + public static final int HAS_RESOURCE_ID = 2; + public static final int HAS_SECOND_LEVEL_RESOURCE = 3; + public static final int HAS_SECOND_LEVEL_ID = 4; + + private static final Logger LOG = LoggerFactory.getLogger(RepositoryRestController.class); + + private URI baseUri = URI.create("http://localhost:8080"); + private MediaType uriListMediaType = MediaType.parseMediaType("text/uri-list"); + private MediaType jsonMediaType = MediaType.parseMediaType("application/x-spring-data+json"); + private JpaRepositoryMetadata repositoryMetadata; + private ConversionService conversionService = new DefaultConversionService(); + private List> httpMessageConverters; + + public URI getBaseUri() { + return baseUri; + } + + public void setBaseUri(URI baseUri) { + this.baseUri = baseUri; + } + + public URI baseUri() { + return baseUri; + } + + public RepositoryRestController baseUri(URI baseUri) { + this.baseUri = baseUri; + return this; + } + + public JpaRepositoryMetadata getRepositoryMetadata() { + return repositoryMetadata; + } + + public void setRepositoryMetadata(JpaRepositoryMetadata repositoryMetadata) { + this.repositoryMetadata = repositoryMetadata; + } + + public JpaRepositoryMetadata repositoryMetadata() { + return repositoryMetadata; + } + + public RepositoryRestController repositoryMetadata(JpaRepositoryMetadata repositoryMetadata) { + this.repositoryMetadata = repositoryMetadata; + return this; + } + + public ConversionService getConversionService() { + return conversionService; + } + + public void setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + } + + public ConversionService conversionService() { + return conversionService; + } + + public RepositoryRestController conversionService(ConversionService conversionService) { + this.conversionService = conversionService; + return this; + } + + public List> getHttpMessageConverters() { + return httpMessageConverters; + } + + public void setHttpMessageConverters(List> httpMessageConverters) { + this.httpMessageConverters = httpMessageConverters; + } + + public List> httpMessageConverters() { + return httpMessageConverters; + } + + public RepositoryRestController httpMessageConverters(List> httpMessageConverters) { + this.httpMessageConverters = httpMessageConverters; + return this; + } + + public MediaType getUriListMediaType() { + return uriListMediaType; + } + + public void setUriListMediaType(MediaType uriListMediaType) { + this.uriListMediaType = uriListMediaType; + } + + public void setUriListMediaType(String uriListMediaType) { + this.uriListMediaType = MediaType.valueOf(uriListMediaType); + } + + public MediaType uriListMediaType() { + return uriListMediaType; + } + + public RepositoryRestController uriListMediaType(MediaType uriListMediaType) { + setUriListMediaType(uriListMediaType); + return this; + } + + public RepositoryRestController uriListMediaType(String uriListMediaType) { + setUriListMediaType(uriListMediaType); + return this; + } + + public MediaType getJsonMediaType() { + return jsonMediaType; + } + + public void setJsonMediaType(MediaType jsonMediaType) { + this.jsonMediaType = jsonMediaType; + } + + public void setJsonMediaType(String jsonMediaType) { + this.jsonMediaType = MediaType.valueOf(jsonMediaType); + } + + public MediaType jsonMediaType() { + return jsonMediaType; + } + + public RepositoryRestController jsonMediaType(MediaType jsonMediaType) { + setJsonMediaType(jsonMediaType); + return this; + } + + public RepositoryRestController jsonMediaType(String jsonMediaType) { + setJsonMediaType(jsonMediaType); + return this; + } + + @Override public void afterPropertiesSet() throws Exception { + Assert.notNull(httpMessageConverters, "HttpMessageConverters cannot be null"); + } + + @SuppressWarnings({"unchecked"}) + @RequestMapping(method = RequestMethod.GET) + public void get(ServerHttpRequest request, final Model model) { + if (validBaseUri(request.getURI())) { + + URI relativeUri = baseUri.relativize(request.getURI()); + final Stack uris = UriUtils.explode(baseUri, relativeUri); + if (LOG.isDebugEnabled()) { + LOG.debug("uris: " + uris); + } + + final int uriCnt = uris.size(); + if (uris.size() > 0) { + final String repoName = uris.get(0).getPath(); + final CrudRepository repo = repositoryMetadata.repositoryFor(repoName); + if (null == repo) { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + final EntityInformation entityInfo = repositoryMetadata.entityInfoFor(repo); + final Class domainClass = entityInfo.getJavaType(); + final Class idType = entityInfo.getIdType(); + final EntityType entityType = repositoryMetadata.entityTypeFor(domainClass); + final JpaEntityMetadata entityMetadata = repositoryMetadata.entityMetadataFor(domainClass); + + switch (uriCnt) { + + // List the entities + case HAS_RESOURCE: { + Map> resource = new HashMap>(); + List links = new ArrayList(); + Iterator iter = repo.findAll().iterator(); + while (iter.hasNext()) { + Object o = iter.next(); + Serializable id = entityInfo.getId(o); + links.add(new SimpleLink(o.getClass().getSimpleName(), + UriComponentsBuilder.fromUri(baseUri) + .pathSegment(repoName, id.toString()) + .build() + .toUri()) + ); + } + resource.put(LINKS, links); + + model.addAttribute(STATUS, HttpStatus.OK); + model.addAttribute(RESOURCE, resource); + return; + } + + // Retrieve an entity + case HAS_RESOURCE_ID: { + final String sId = UriUtils.path(uris.get(1)); + Serializable serId; + if (idType == String.class) { + serId = sId; + } else { + serId = conversionService.convert(sId, idType); + } + + final Object entity = repo.findOne(serId); + if (null == entity) { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + } else { + Map entityDto = extractPropertiesLinkAware(entity, entityMetadata, repoName, sId); + addSelfLink(entityDto, repoName, sId); + + model.addAttribute(STATUS, HttpStatus.OK); + model.addAttribute(RESOURCE, entityDto); + } + return; + } + + // Retrieve the linked entities + case HAS_SECOND_LEVEL_RESOURCE: + // Retrieve a child entity + case HAS_SECOND_LEVEL_ID: { + final String sId = UriUtils.path(uris.get(1)); + final Serializable serId; + if (idType == String.class) { + serId = sId; + } else { + serId = conversionService.convert(sId, idType); + } + + Object entity = repo.findOne(serId); + if (null == entity) { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + } else { + model.addAttribute(STATUS, HttpStatus.OK); + final String attrName = UriUtils.path(uris.get(2)); + Attribute attr = entityType.getAttribute(attrName); + if (null != attr) { + Class childType; + if (attr instanceof PluralAttribute) { + childType = ((PluralAttribute) attr).getElementType().getJavaType(); + } else { + childType = attr.getJavaType(); + } + final CrudRepository childRepo = repositoryMetadata.repositoryFor(childType); + if (null == childRepo) { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + final EntityInformation childEntityInfo = repositoryMetadata.entityInfoFor(childRepo); + final JpaEntityMetadata childEntityMetadata = repositoryMetadata.entityMetadataFor(childEntityInfo.getJavaType()); + + final Object child = entityMetadata.get(attrName, entity); + if (uriCnt == 3) { + Map resource = new HashMap(); + if (null != child) { + if (child instanceof Collection) { + List links = new ArrayList(); + for (Object o : (Collection) child) { + String childId = childEntityInfo.getId(o).toString(); + URI uri = UriComponentsBuilder.fromUri(baseUri) + .pathSegment(repoName, sId, attrName, childId) + .build() + .toUri(); + links.add(new SimpleLink(childType.getSimpleName(), uri)); + } + resource.put(LINKS, links); + model.addAttribute(RESOURCE, resource); + } else if (child instanceof Map) { + List links = new ArrayList(); + for (Map.Entry entry : ((Map) child).entrySet()) { + String childId = childEntityInfo.getId(entry.getValue()).toString(); + URI uri = UriComponentsBuilder.fromUri(baseUri) + .pathSegment(repoName, sId, attrName, childId) + .build() + .toUri(); + Object oKey = entry.getKey(); + String sKey; + if (ClassUtils.isAssignable(oKey.getClass(), String.class)) { + sKey = (String) oKey; + } else { + sKey = conversionService.convert(oKey, String.class); + } + links.add(new SimpleLink(sKey, uri)); + } + resource.put(attrName, links); + model.addAttribute(RESOURCE, resource); + } else { + model.addAttribute(RESOURCE, child); + } + } + } else if (uriCnt == 4) { + final String childId = UriUtils.path(uris.get(3)); + Class childIdType = childEntityInfo.getIdType(); + final Serializable childSerId; + if (idType == String.class) { + childSerId = childId; + } else { + childSerId = conversionService.convert(childId, childIdType); + } + + final Object o = childRepo.findOne(childSerId); + if (null != o) { + Map entityDto = extractPropertiesLinkAware(o, + childEntityMetadata, + repoName, + sId, + attrName); + addSelfLink(entityDto, repositoryMetadata.repositoryNameFor(childRepo), childId); + model.addAttribute(RESOURCE, entityDto); + } else { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + } + } + } + } + + return; + } + + // List the repositories + default: + } + } + } else { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + + model.addAttribute(STATUS, HttpStatus.OK); + + Map> resource = new HashMap>(); + List links = new ArrayList(); + for (String name : repositoryMetadata.repositoryNames()) { + links.add(new SimpleLink(name, + UriComponentsBuilder.fromUri(baseUri) + .pathSegment(name) + .build() + .toUri()) + ); + } + resource.put(LINKS, links); + + model.addAttribute(RESOURCE, resource); + } + + @SuppressWarnings({"unchecked"}) + @RequestMapping(method = {RequestMethod.POST, RequestMethod.PUT}) + public void createOrUpdate(ServerHttpRequest request, Model model) { + if (validBaseUri(request.getURI())) { + + URI relativeUri = baseUri.relativize(request.getURI()); + final Stack uris = UriUtils.explode(baseUri, relativeUri); + if (LOG.isDebugEnabled()) { + LOG.debug("uris: " + uris); + } + + final int uriCnt = uris.size(); + if (uriCnt > 0) { + final String repoName = UriUtils.path(uris.get(0)); + final CrudRepository repo = repositoryMetadata.repositoryFor(repoName); + if (null == repo) { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + final EntityInformation entityInfo = repositoryMetadata.entityInfoFor(repo); + final Class domainClass = entityInfo.getJavaType(); + final Class idType = entityInfo.getIdType(); + final JpaEntityMetadata entityMetadata = repositoryMetadata.entityMetadataFor(domainClass); + final MediaType incomingMediaType = request.getHeaders().getContentType(); + + switch (uriCnt) { + + // Create a new entity + case HAS_RESOURCE: + case HAS_RESOURCE_ID: { + if (incomingMediaType.equals(jsonMediaType)) { + try { + final Map incoming = readIncoming(request, incomingMediaType, Map.class); + if (null == incoming) { + model.addAttribute(STATUS, HttpStatus.NOT_ACCEPTABLE); + } else { + String resourceId; + Serializable serId = null; + if (uriCnt == HAS_RESOURCE_ID) { + resourceId = UriUtils.path(uris.get(1)); + if (idType == String.class) { + serId = resourceId; + } else { + serId = conversionService.convert(resourceId, idType); + } + } + + final Object entity = request.getMethod() == HttpMethod.PUT ? + repo.findOne(serId) : + entityMetadata.targetType().newInstance(); + + entityMetadata.doWithEmbedded(new Handler() { + @Override public Void handle(Attribute attribute) { + String name = attribute.getName(); + if (incoming.containsKey(name)) { + Object val = incoming.get(name); + entityMetadata.set(name, val, entity); + } + return null; + } + }); + + if (uriCnt == HAS_RESOURCE_ID && request.getMethod() == HttpMethod.POST) { + entityMetadata.id(serId, entity); + } + + Object savedEntity = repo.save(entity); + String sId = entityInfo.getId(savedEntity).toString(); + + URI selfUri = UriComponentsBuilder.fromUri(baseUri) + .pathSegment(repoName, sId) + .build() + .toUri(); + + if (request.getMethod() == HttpMethod.POST) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Location", selfUri.toString()); + model.addAttribute(HEADERS, headers); + model.addAttribute(STATUS, HttpStatus.CREATED); + } else { + model.addAttribute(STATUS, HttpStatus.NO_CONTENT); + } + } + } catch (Exception e) { + LOG.error(e.getMessage(), e); + model.addAttribute(STATUS, HttpStatus.BAD_REQUEST); + } + return; + } else { + model.addAttribute(STATUS, HttpStatus.NOT_ACCEPTABLE); + return; + } + } + + case HAS_SECOND_LEVEL_RESOURCE: { + String propertyName = UriUtils.path(uris.get(2)); + Attribute attr = entityMetadata.linkedAttributes().get(propertyName); + if (null != attr) { + Object entity = resolveTopLevelResource(request.getURI().toString()); + if (null != entity) { + try { + if (incomingMediaType.equals(uriListMediaType)) { + BufferedReader in = new BufferedReader(new InputStreamReader(request.getBody())); + String line; + while (null != (line = in.readLine())) { + String sLinkUri = line.trim(); + Object childEntity = resolveTopLevelResource(sLinkUri); + + if (attr instanceof PluralAttribute) { + PluralAttribute plAttr = (PluralAttribute) attr; + switch (plAttr.getCollectionType()) { + case COLLECTION: + case LIST: + if (request.getMethod() == HttpMethod.PUT) { + entityMetadata.set(propertyName, new ArrayList(), entity); + } + addToCollection(propertyName, entity, entityMetadata, ArrayList.class, childEntity); + break; + case SET: + if (request.getMethod() == HttpMethod.PUT) { + entityMetadata.set(propertyName, new HashSet(), entity); + } + addToCollection(propertyName, entity, entityMetadata, HashSet.class, childEntity); + break; + case MAP: + model.addAttribute(STATUS, HttpStatus.UNSUPPORTED_MEDIA_TYPE); + return; + } + } else if (attr instanceof SingularAttribute) { + entityMetadata.set(propertyName, childEntity, entity); + } + } + repo.save(entity); + if (request.getMethod() == HttpMethod.PUT) { + model.addAttribute(STATUS, HttpStatus.NO_CONTENT); + } else { + model.addAttribute(STATUS, HttpStatus.CREATED); + } + } else if (incomingMediaType.equals(jsonMediaType)) { + final List> incoming = readIncoming(request, incomingMediaType, List.class); + for (Map link : incoming) { + String sLinkUri = link.get("href"); + Object childEntity = resolveTopLevelResource(sLinkUri); + + if (attr instanceof PluralAttribute) { + PluralAttribute plAttr = (PluralAttribute) attr; + switch (plAttr.getCollectionType()) { + case COLLECTION: + case LIST: + if (request.getMethod() == HttpMethod.PUT) { + entityMetadata.set(propertyName, new ArrayList(), entity); + } + addToCollection(propertyName, entity, entityMetadata, ArrayList.class, childEntity); + break; + case SET: + if (request.getMethod() == HttpMethod.PUT) { + entityMetadata.set(propertyName, new HashSet(), entity); + } + addToCollection(propertyName, entity, entityMetadata, HashSet.class, childEntity); + break; + case MAP: + if (request.getMethod() == HttpMethod.PUT) { + entityMetadata.set(propertyName, new HashMap(), entity); + } + addToMap(propertyName, entity, entityMetadata, link.get("rel"), childEntity); + break; + } + } else if (attr instanceof SingularAttribute) { + entityMetadata.set(propertyName, childEntity, entity); + } + } + repo.save(entity); + if (request.getMethod() == HttpMethod.PUT) { + model.addAttribute(STATUS, HttpStatus.NO_CONTENT); + } else { + model.addAttribute(STATUS, HttpStatus.CREATED); + } + } + } catch (IOException e) { + model.addAttribute(STATUS, HttpStatus.INTERNAL_SERVER_ERROR); + } catch (InstantiationException e) { + model.addAttribute(STATUS, HttpStatus.BAD_REQUEST); + } catch (IllegalAccessException e) { + model.addAttribute(STATUS, HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + } + } + return; + } + + // List resources + default: + } + } + } else { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + + model.addAttribute(STATUS, HttpStatus.OK); + + Map> resource = new HashMap>(); + List links = new ArrayList(); + for (String name : repositoryMetadata.repositoryNames()) { + links.add(new SimpleLink(name, + UriComponentsBuilder.fromUri(baseUri) + .pathSegment(name) + .build() + .toUri()) + ); + } + resource.put(LINKS, links); + + model.addAttribute(RESOURCE, resource); + } + + @SuppressWarnings({"unchecked"}) + @RequestMapping(method = RequestMethod.DELETE) + public void delete(ServerHttpRequest request, Model model) { + if (validBaseUri(request.getURI())) { + + URI relativeUri = baseUri.relativize(request.getURI()); + Stack uris = UriUtils.explode(baseUri, relativeUri); + if (LOG.isDebugEnabled()) { + LOG.debug("uris: " + uris); + } + + int uriCnt = uris.size(); + if (uriCnt > 0) { + String repoName = UriUtils.path(uris.get(0)); + CrudRepository repo = repositoryMetadata.repositoryFor(repoName); + if (null == repo) { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + EntityInformation entityInfo = repositoryMetadata.entityInfoFor(repo); + Class domainClass = entityInfo.getJavaType(); + Class idType = entityInfo.getIdType(); + JpaEntityMetadata entityMetadata = repositoryMetadata.entityMetadataFor(domainClass); + + switch (uriCnt) { + + case HAS_RESOURCE_ID: { + String resourceId = UriUtils.path(uris.get(1)); + Serializable serId = conversionService.convert(resourceId, idType); + repo.delete(serId); + model.addAttribute(STATUS, HttpStatus.NO_CONTENT); + return; + } + + case HAS_SECOND_LEVEL_ID: { + String resourceId = UriUtils.path(uris.get(1)); + Serializable serId = conversionService.convert(resourceId, idType); + + Object entity = repo.findOne(serId); + + String propertyName = UriUtils.path(uris.get(2)); + Attribute attr = entityMetadata.linkedAttributes().get(propertyName); + if (null != attr && null != entity) { + Object childEntity = resolveSecondLevelResource(request.getURI().toString()); + if (attr instanceof PluralAttribute) { + PluralAttribute plAttr = (PluralAttribute) attr; + switch (plAttr.getCollectionType()) { + case COLLECTION: + case LIST: + case SET: + removeFromCollection(propertyName, entity, entityMetadata, childEntity); + break; + case MAP: + removeFromMap(propertyName, entity, entityMetadata, childEntity); + break; + } + } else { + entityMetadata.set(propertyName, null, entity); + } + + repo.save(entity); + + model.addAttribute(STATUS, HttpStatus.NO_CONTENT); + return; + } else { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + } + + } + } + } else { + model.addAttribute(STATUS, HttpStatus.NOT_FOUND); + return; + } + + model.addAttribute(STATUS, HttpStatus.OK); + + Map> resource = new HashMap>(); + List links = new ArrayList(); + for (String name : repositoryMetadata.repositoryNames()) { + links.add(new SimpleLink(name, + UriComponentsBuilder.fromUri(baseUri) + .pathSegment(name) + .build() + .toUri()) + ); + } + resource.put(LINKS, links); + + model.addAttribute(RESOURCE, resource); + } + + + private boolean validBaseUri(URI requestUri) { + String path = baseUri.relativize(requestUri).getPath(); + return !StringUtils.hasText(path) || path.charAt(0) != '/'; + } + + @SuppressWarnings({"unchecked"}) + private void addSelfLink(Map model, String... pathComponents) { + List links = (List) model.get(LINKS); + if (null == links) { + links = new ArrayList(); + model.put(LINKS, links); + } + URI selfUri = UriComponentsBuilder.fromUri(baseUri) + .pathSegment(pathComponents) + .build() + .toUri(); + links.add(new SimpleLink(SELF, selfUri)); + } + + @SuppressWarnings({"unchecked"}) + private V stringToSerializable(String s, Class targetType) { + if (ClassUtils.isAssignable(targetType, String.class)) { + return (V) s; + } else { + return conversionService.convert(s, targetType); + } + } + + @SuppressWarnings({"unchecked"}) + private V readIncoming(HttpInputMessage request, MediaType incomingMediaType, Class targetType) throws IOException { + for (HttpMessageConverter converter : httpMessageConverters) { + if (converter.canRead(targetType, incomingMediaType)) { + return (V) converter.read(targetType, request); + } + } + return null; + } + + @SuppressWarnings({"unchecked"}) + private Object resolveTopLevelResource(String uri) { + URI href = URI.create(uri); + if (validBaseUri(href)) { + URI relativeUri = baseUri.relativize(href); + Stack uris = UriUtils.explode(baseUri, relativeUri); + + if (uris.size() > 1) { + String repoName = UriUtils.path(uris.get(0)); + String sId = UriUtils.path(uris.get(1)); + + CrudRepository repo = repositoryMetadata.repositoryFor(repoName); + EntityInformation entityInfo = repositoryMetadata.entityInfoFor(repo); + Class idType = entityInfo.getIdType(); + + Serializable serId = stringToSerializable(sId, idType); + + return repo.findOne(serId); + } + } + return null; + + } + + @SuppressWarnings({"unchecked"}) + private Object resolveSecondLevelResource(String uri) { + URI href = URI.create(uri); + if (validBaseUri(href)) { + URI relativeUri = baseUri.relativize(href); + Stack uris = UriUtils.explode(baseUri, relativeUri); + + if (uris.size() > 3) { + String topLevelRepoName = UriUtils.path(uris.get(0)); + CrudRepository topLevelRepo = repositoryMetadata.repositoryFor(topLevelRepoName); + EntityInformation topLevelEntityInfo = repositoryMetadata.entityInfoFor(topLevelRepo); + JpaEntityMetadata topLevelEntityMetadata = repositoryMetadata.entityMetadataFor(topLevelEntityInfo.getJavaType()); + + String propertyName = UriUtils.path(uris.get(2)); + Attribute attr = topLevelEntityMetadata.linkedAttributes().get(propertyName); + if (null != attr) { + CrudRepository secondLevelRepo; + if (attr instanceof PluralAttribute) { + secondLevelRepo = repositoryMetadata.repositoryFor(((PluralAttribute) attr).getElementType().getJavaType()); + } else { + secondLevelRepo = repositoryMetadata.repositoryFor(attr.getJavaType()); + } + EntityInformation secondLevelEntityInfo = repositoryMetadata.entityInfoFor(secondLevelRepo); + Class secondLevelIdType = secondLevelEntityInfo.getIdType(); + Serializable secondLevelId = stringToSerializable(UriUtils.path(uris.get(3)), secondLevelIdType); + + return secondLevelRepo.findOne(secondLevelId); + } + } + } + return null; + } + + @SuppressWarnings({"unchecked"}) + private void addToCollection(String name, + Object entity, + JpaEntityMetadata metadata, + Class containerClass, + Object obj) + throws IllegalAccessException, + InstantiationException { + Collection c = (V) metadata.get(name, entity); + if (null == c) { + c = containerClass.newInstance(); + metadata.set(name, c, entity); + } + c.add(obj); + } + + public void removeFromCollection(String name, + Object entity, + JpaEntityMetadata metadata, + Object obj) { + Collection c = (Collection) metadata.get(name, entity); + if (null != c) { + c.remove(obj); + } + } + + @SuppressWarnings({"unchecked"}) + private void addToMap(String name, + Object entity, + JpaEntityMetadata metadata, + Object key, + Object obj) + throws IllegalAccessException, + InstantiationException { + Map m = (Map) metadata.get(name, entity); + if (null == m) { + m = new HashMap(); + metadata.set(name, m, entity); + } + m.put(key, obj); + } + + @SuppressWarnings({"unchecked"}) + public void removeFromMap(String name, + Object entity, + JpaEntityMetadata metadata, + Object obj) { + Map m = (Map) metadata.get(name, entity); + if (null != m) { + LOG.debug("obj: " + obj); + for (Map.Entry entry : m.entrySet()) { + LOG.debug("key: " + entry.getKey()); + LOG.debug("value: " + entry.getValue()); + LOG.debug("remove? " + (entry.getValue() == obj || entry.getValue().equals(obj))); + if (entry.getValue() == obj || entry.getValue().equals(obj)) { + m.remove(entry.getKey()); + } + } + } + } + + @SuppressWarnings({"unchecked"}) + private Map extractPropertiesLinkAware(final Object entity, + final JpaEntityMetadata entityMetadata, + final String... pathSegs) { + final Map entityDto = new HashMap(); + + entityMetadata.doWithEmbedded(new Handler() { + @Override public Void handle(Attribute attr) { + String name = attr.getName(); + Object val = entityMetadata.get(name, entity); + if (null != val) { + entityDto.put(name, val); + } + return null; + } + }); + + entityMetadata.doWithLinked(new Handler() { + @Override public Void handle(Attribute attr) { + String name = attr.getName(); + URI uri = UriComponentsBuilder.fromUri(baseUri) + .pathSegment(pathSegs) + .pathSegment(name) + .build() + .toUri(); + Link l = new SimpleLink(name, uri); + List links = (List) entityDto.get(LINKS); + if (null == links) { + links = new ArrayList(); + entityDto.put(LINKS, links); + } + links.add(l); + return null; + } + }); + + return entityDto; + } + +} diff --git a/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestMvcConfiguration.java b/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestMvcConfiguration.java new file mode 100644 index 000000000..6a4bfb31c --- /dev/null +++ b/rest/src/main/java/org/springframework/data/rest/mvc/RepositoryRestMvcConfiguration.java @@ -0,0 +1,81 @@ +package org.springframework.data.rest.mvc; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver; +import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver; +import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; + +/** + * @author Jon Brisbin + */ +@Configuration +public class RepositoryRestMvcConfiguration { + + @Autowired + RepositoryRestConfiguration repositoryRestConfiguration; + RepositoryRestController repositoryRestController; + + @Bean ContentNegotiatingViewResolver contentNegotiatingViewResolver() { + ContentNegotiatingViewResolver viewResolver = new ContentNegotiatingViewResolver(); + Map jsonTypes = new HashMap() {{ + put("json", "application/json"); + put("sdjson", "application/x-spring-data+json"); + put("urilist", "text/uri-list"); + }}; + + viewResolver.setMediaTypes(jsonTypes); + viewResolver.setDefaultViews( + Arrays.asList((View) new JsonView("application/json"), + (View) new JsonView("application/x-spring-data+json"), + (View) new UriListView()) + ); + return viewResolver; + } + + @Bean RepositoryRestController repositoryRestController() throws Exception { + if (null == repositoryRestController) { + this.repositoryRestController = new RepositoryRestController() + .baseUri(repositoryRestConfiguration.baseUri()) + .repositoryMetadata(repositoryRestConfiguration.jpaRepositoryMetadata()) + .httpMessageConverters(repositoryRestConfiguration.httpMessageConverters()) + .jsonMediaType("application/json"); + } + return repositoryRestController; + } + + @Bean RequestMappingHandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + @Bean RequestMappingHandlerAdapter handlerAdapter() { + RequestMappingHandlerAdapter handlerAdapter = new RequestMappingHandlerAdapter(); + handlerAdapter.setCustomArgumentResolvers( + Arrays.asList((HandlerMethodArgumentResolver) new ServerHttpRequestMethodArgumentResolver()) + ); + return handlerAdapter; + } + + @Bean ExceptionHandlerExceptionResolver exceptionHandlerExceptionResolver() { + return new ExceptionHandlerExceptionResolver(); + } + + @Bean DefaultHandlerExceptionResolver handlerExceptionResolver() { + return new DefaultHandlerExceptionResolver(); + } + + @Bean ResponseStatusExceptionResolver responseStatusExceptionResolver() { + return new ResponseStatusExceptionResolver(); + } + +} diff --git a/rest/src/main/java/org/springframework/data/rest/mvc/ServerHttpRequestMethodArgumentResolver.java b/rest/src/main/java/org/springframework/data/rest/mvc/ServerHttpRequestMethodArgumentResolver.java new file mode 100644 index 000000000..6f292f9fb --- /dev/null +++ b/rest/src/main/java/org/springframework/data/rest/mvc/ServerHttpRequestMethodArgumentResolver.java @@ -0,0 +1,31 @@ +package org.springframework.data.rest.mvc; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.core.MethodParameter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.util.ClassUtils; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * @author Jon Brisbin + */ +public class ServerHttpRequestMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override public boolean supportsParameter(MethodParameter parameter) { + return ClassUtils.isAssignable(parameter.getParameterType(), ServerHttpRequest.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + return new ServletServerHttpRequest((HttpServletRequest) webRequest.getNativeRequest()); + } + +} diff --git a/rest/src/main/java/org/springframework/data/rest/mvc/UriListView.java b/rest/src/main/java/org/springframework/data/rest/mvc/UriListView.java new file mode 100644 index 000000000..79f5563fb --- /dev/null +++ b/rest/src/main/java/org/springframework/data/rest/mvc/UriListView.java @@ -0,0 +1,68 @@ +package org.springframework.data.rest.mvc; + +import java.io.PrintWriter; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.data.rest.core.Link; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.view.AbstractView; + +/** + * @author Jon Brisbin + */ +public class UriListView extends AbstractView { + + public UriListView() { + setContentType("text/uri-list"); + } + + @SuppressWarnings({"unchecked"}) + @Override + protected void renderMergedOutputModel(Map model, + HttpServletRequest request, + HttpServletResponse response) throws Exception { + + Object resource = model.get("resource"); + response.setContentType(getContentType()); + + HttpStatus status = (HttpStatus) model.get("status"); + HttpHeaders headers = (HttpHeaders) model.get("headers"); + List links = null; + if (resource instanceof List) { + links = (List) resource; + } else if (resource instanceof Map) { + Map m = (Map) resource; + Object o = m.get("_links"); + if (null != o && o instanceof List) { + links = (List) o; + } else { + response.setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); + return; + } + } + + if (null != status) { + response.setStatus(status.value()); + } + + if (null != headers) { + for (Map.Entry entry : headers.toSingleValueMap().entrySet()) { + response.setHeader(entry.getKey(), entry.getValue()); + } + } + + PrintWriter out = response.getWriter(); + if (null != links) { + for (Link l : links) { + out.println(l.href().toString()); + } + } + out.flush(); + + } + +} diff --git a/rest/src/main/webapp/WEB-INF/repositories.xml b/rest/src/main/webapp/WEB-INF/repositories.xml new file mode 100644 index 000000000..efac5558f --- /dev/null +++ b/rest/src/main/webapp/WEB-INF/repositories.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/rest/src/main/webapp/WEB-INF/web.xml b/rest/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 000000000..ad7d31d4b --- /dev/null +++ b/rest/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,48 @@ + + + + + contextClass + org.springframework.web.context.support.AnnotationConfigWebApplicationContext + + + contextConfigLocation + org.springframework.data.rest.mvc.RepositoryRestConfiguration + + + org.springframework.web.context.ContextLoaderListener + + + + entityManagerInViewFilter + org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter + + + + exporter + org.springframework.web.servlet.DispatcherServlet + + contextClass + org.springframework.web.context.support.AnnotationConfigWebApplicationContext + + + contextConfigLocation + org.springframework.data.rest.mvc.RepositoryRestMvcConfiguration + + 1 + + + + entityManagerInViewFilter + exporter + + + + exporter + /* + + + diff --git a/rest/src/test/java/org/springframework/data/rest/test/RestBuilder.java b/rest/src/test/java/org/springframework/data/rest/test/RestBuilder.java new file mode 100644 index 000000000..0dc9e4915 --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/RestBuilder.java @@ -0,0 +1,225 @@ +package org.springframework.data.rest.test; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import groovy.lang.Closure; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.util.ClassUtils; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +/** + * @author Jon Brisbin + */ +public class RestBuilder { + + private static final String[] DATE_FORMATS = new String[]{ + "EEE, dd MMM yyyy HH:mm:ss z", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "yyyy-MM-dd HH:mm:ss" + }; + + private ConversionService conversionService = new DefaultConversionService(); + private ClientHttpRequestFactory requestFactory; + private RestTemplate restTemplate; + private HttpHeaders headers = new HttpHeaders(); + private MediaType contentType; + private Class responseType = byte[].class; + private Map uriParams; + private Object body; + private Closure errorHandler; + + public RestBuilder() { + this.restTemplate = new RestTemplate(); + } + + public RestBuilder(ClientHttpRequestFactory requestFactory) { + this.requestFactory = requestFactory; + this.restTemplate = new RestTemplate(requestFactory); + } + + public Object call(Closure cl) { + RestBuilder b = null != requestFactory ? new RestBuilder(requestFactory) : new RestBuilder(); + if (null != errorHandler) { + b.setErrorHandler(errorHandler); + } + b.conversionService = conversionService; + cl.setDelegate(b); + + return cl.call(); + } + + public Object delete(String url) { + restTemplate.delete(url); + return this; + } + + @SuppressWarnings({"unchecked"}) + public Object get(String url) { + return restTemplate.getForEntity(maybeAddParams(url), responseType); + } + + @SuppressWarnings({"unchecked"}) + public Object post(String url) { + if (responseType == URI.class) { + return restTemplate.postForLocation(maybeAddParams(url), new HttpEntity(body, headers)); + } else { + return restTemplate.postForEntity(maybeAddParams(url), new HttpEntity(body, headers), responseType); + } + } + + @SuppressWarnings({"unchecked"}) + public Object put(String url) { + if (null != uriParams) { + restTemplate.put(maybeAddParams(url), new HttpEntity(body, headers), uriParams); + } else { + restTemplate.put(maybeAddParams(url), new HttpEntity(body, headers)); + } + return this; + } + + public Object accept(String accept) { + headers.setAccept(MediaType.parseMediaTypes(accept)); + return this; + } + + public Object body(Object body) { + this.body = body; + return this; + } + + public Object contentType(String contentType) { + this.contentType = MediaType.parseMediaType(contentType); + headers.setContentType(this.contentType); + return this; + } + + public Object date(Date date) { + headers.setDate(date.getTime()); + return this; + } + + @SuppressWarnings({"unchecked"}) + public Object date(String date) { + for (String fmt : DATE_FORMATS) { + try { + Date dte = new SimpleDateFormat(fmt).parse(date); + headers.setDate(dte.getTime()); + break; + } catch (ParseException e) {} + } + return this; + } + + @SuppressWarnings({"unchecked"}) + public Object header(String key, Object val) { + if (null != val) { + if (val instanceof List) { + headers.put(key, (List) val); + } else if (ClassUtils.isAssignable(val.getClass(), String.class)) { + headers.set(key, (String) val); + } else { + headers.set(key, conversionService.convert(val, String.class)); + } + } else { + headers.remove(key); + } + return this; + } + + @SuppressWarnings({"unchecked"}) + public Object headers(Map headers) { + this.headers.putAll(headers); + return this; + } + + public Date now() { + return Calendar.getInstance().getTime(); + } + + @SuppressWarnings({"unchecked"}) + public Object param(String key, String value) { + if (null == uriParams) { + uriParams = new HashMap(); + } + uriParams.put(key, value); + return this; + } + + public Object params(Map params) { + this.uriParams = params; + return this; + } + + public Object responseType(Class responseType) { + this.responseType = responseType; + return this; + } + + public Object setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + return this; + } + + public Object setErrorHandler(Closure errorHandler) { + this.errorHandler = errorHandler; + if (null != errorHandler) { + this.restTemplate.setErrorHandler(new DefaultResponseErrorHandler() { + @Override public void handleError(ClientHttpResponse response) throws IOException { + RestBuilder.this.errorHandler.call(response); + } + }); + } + return this; + } + + public Object setMessageConverters(List> converters) { + restTemplate.setMessageConverters(converters); + return this; + } + + @SuppressWarnings({"unchecked"}) + private String maybeAddParams(String url) { + StringBuffer buff = new StringBuffer(url); + if (null != uriParams) { + buff.append("?"); + for (Map.Entry entry : ((Map) uriParams).entrySet()) { + try { + buff.append(entry.getKey()).append("=").append(URLEncoder.encode(entry.getValue(), "UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + } + return buff.toString(); + } + + @Override public String toString() { + return "RestBuilder{" + + "requestFactory=" + requestFactory + + ", restTemplate=" + restTemplate + + ", headers=" + headers + + ", params=" + uriParams + + ", contentType=" + contentType + + ", errorHandler=" + errorHandler + + '}'; + } + +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/Address.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/Address.java new file mode 100644 index 000000000..9b5bfb546 --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/Address.java @@ -0,0 +1,61 @@ +package org.springframework.data.rest.test.mvc; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +/** + * @author Jon Brisbin + */ +@Entity +public class Address { + + @Id @GeneratedValue private Long id; + private String[] lines; + private String city; + private String province; + private String postalCode; + + public Address() { + } + + public Address(String[] lines, String city, String province, String postalCode) { + this.lines = lines; + this.city = city; + this.province = province; + this.postalCode = postalCode; + } + + public String[] getLines() { + return lines; + } + + public void setLines(String[] lines) { + this.lines = lines; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getProvince() { + return province; + } + + public void setProvince(String province) { + this.province = province; + } + + public String getPostalCode() { + return postalCode; + } + + public void setPostalCode(String postalCode) { + this.postalCode = postalCode; + } + +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/AddressRepository.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/AddressRepository.java new file mode 100644 index 000000000..3e970fe16 --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/AddressRepository.java @@ -0,0 +1,9 @@ +package org.springframework.data.rest.test.mvc; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Jon Brisbin + */ +public interface AddressRepository extends CrudRepository { +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/Person.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/Person.java new file mode 100644 index 000000000..3d21b718a --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/Person.java @@ -0,0 +1,77 @@ +package org.springframework.data.rest.test.mvc; + +import java.util.List; +import java.util.Map; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.OneToMany; +import javax.persistence.Version; + +/** + * @author Jon Brisbin + */ +@Entity +public class Person { + + @Id private Long id; + private String name; + @Version + private Long version; + @OneToMany + private List
addresses; + @OneToMany + private Map profiles; + + public Person() { + } + + public Person(Long id, String name, List
addresses, Map profiles) { + this.id = id; + this.name = name; + this.addresses = addresses; + this.profiles = profiles; + } + + public Person(String name, List
addresses, Map profiles) { + this.name = name; + this.addresses = addresses; + this.profiles = profiles; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } + + public List
getAddresses() { + return addresses; + } + + public void setAddresses(List
addresses) { + this.addresses = addresses; + } + + public Map getProfiles() { + return profiles; + } + + public void setProfiles(Map profiles) { + this.profiles = profiles; + } + +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/PersonLoader.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/PersonLoader.java new file mode 100644 index 000000000..d563839ce --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/PersonLoader.java @@ -0,0 +1,63 @@ +package org.springframework.data.rest.test.mvc; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.InitializingBean; + +/** + * @author Jon Brisbin + */ +public class PersonLoader implements InitializingBean { + + private PersonRepository personRepository; + private ProfileRepository profileRepository; + private AddressRepository addressRepository; + + public PersonRepository getPersonRepository() { + return personRepository; + } + + public void setPersonRepository(PersonRepository personRepository) { + this.personRepository = personRepository; + } + + public ProfileRepository getProfileRepository() { + return profileRepository; + } + + public void setProfileRepository(ProfileRepository profileRepository) { + this.profileRepository = profileRepository; + } + + public AddressRepository getAddressRepository() { + return addressRepository; + } + + public void setAddressRepository(AddressRepository addressRepository) { + this.addressRepository = addressRepository; + } + + @Override public void afterPropertiesSet() throws Exception { + Address pers1addr = addressRepository.save(new Address(new String[]{"1234 W. 1st St."}, "Univille", "ST", "12345")); + + Map pers1profiles = new HashMap(); + Profile twitter = profileRepository.save(new Profile("twitter", "#!/johndoe")); + Profile fb = profileRepository.save(new Profile("facebook", "/johndoe")); + pers1profiles.put("twitter", twitter); + pers1profiles.put("facebook", fb); + + personRepository.save(new Person(1L, "John Doe", Arrays.asList(pers1addr), pers1profiles)); + + Address pers2addr = addressRepository.save(new Address(new String[]{"1234 E. 2nd St."}, "Univille", "ST", "12345")); + + Map pers2profiles = new HashMap(); + Profile twitter2 = profileRepository.save(new Profile("twitter", "#!/janedoe")); + Profile fb2 = profileRepository.save(new Profile("facebook", "/janedoe")); + pers2profiles.put("facebook", fb2); + + personRepository.save(new Person(2L, "Jane Doe", Arrays.asList(pers2addr), pers2profiles)); + } + +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/PersonRepository.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/PersonRepository.java new file mode 100644 index 000000000..2a24995b2 --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/PersonRepository.java @@ -0,0 +1,9 @@ +package org.springframework.data.rest.test.mvc; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Jon Brisbin + */ +public interface PersonRepository extends CrudRepository { +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/Profile.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/Profile.java new file mode 100644 index 000000000..80ecb82bc --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/Profile.java @@ -0,0 +1,80 @@ +package org.springframework.data.rest.test.mvc; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +/** + * @author Jon Brisbin + */ +@Entity +public class Profile { + + @Id @GeneratedValue private Long id; + private String type; + private String url; + + public Profile() { + } + + public Profile(String type, String url) { + this.type = type; + this.url = url; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + @Override public boolean equals(Object o) { + if (!(o instanceof Profile)) { + return false; + } + + Profile p2 = (Profile) o; + + boolean idEq; + if (null != id) { + idEq = id.equals(p2.id); + } else { + idEq = p2.id == null; + } + + boolean typeEq; + if (null != type) { + typeEq = type.equals(p2.type); + } else { + typeEq = p2.type == null; + } + + boolean urlEq; + if (null != url) { + urlEq = url.equals(p2.url); + } else { + urlEq = p2.url == null; + } + + return idEq && typeEq && urlEq; + } + + @Override public String toString() { + return "Profile{" + + "id=" + id + + ", type='" + type + '\'' + + ", url='" + url + '\'' + + '}'; + } + +} diff --git a/rest/src/test/java/org/springframework/data/rest/test/mvc/ProfileRepository.java b/rest/src/test/java/org/springframework/data/rest/test/mvc/ProfileRepository.java new file mode 100644 index 000000000..f1fba83bb --- /dev/null +++ b/rest/src/test/java/org/springframework/data/rest/test/mvc/ProfileRepository.java @@ -0,0 +1,9 @@ +package org.springframework.data.rest.test.mvc; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Jon Brisbin + */ +public interface ProfileRepository extends CrudRepository { +} diff --git a/rest/src/test/resouces/META-INF/persistence.xml b/rest/src/test/resouces/META-INF/persistence.xml new file mode 100644 index 000000000..d41aed326 --- /dev/null +++ b/rest/src/test/resouces/META-INF/persistence.xml @@ -0,0 +1,16 @@ + + + + org.springframework.data.rest.test.mvc.Person + org.springframework.data.rest.test.mvc.Profile + org.springframework.data.rest.test.mvc.Address + + + + + + + + + + \ No newline at end of file diff --git a/rest/src/test/resouces/META-INF/spring-data-rest/repositories-export.xml b/rest/src/test/resouces/META-INF/spring-data-rest/repositories-export.xml new file mode 100644 index 000000000..57ceb44e3 --- /dev/null +++ b/rest/src/test/resouces/META-INF/spring-data-rest/repositories-export.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rest/src/test/resouces/META-INF/spring-data-rest/shared.xml b/rest/src/test/resouces/META-INF/spring-data-rest/shared.xml new file mode 100644 index 000000000..522ed1555 --- /dev/null +++ b/rest/src/test/resouces/META-INF/spring-data-rest/shared.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rest/src/test/resouces/logback.xml b/rest/src/test/resouces/logback.xml new file mode 100644 index 000000000..77311749e --- /dev/null +++ b/rest/src/test/resouces/logback.xml @@ -0,0 +1,19 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..df8586390 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,3 @@ +include "core", + "repository", + "rest"