diff --git a/spring-content-commons/src/main/asciidoc/content-repositories.adoc b/spring-content-commons/src/main/asciidoc/content-repositories.adoc index 435c2ec99..63bafd133 100644 --- a/spring-content-commons/src/main/asciidoc/content-repositories.adoc +++ b/spring-content-commons/src/main/asciidoc/content-repositories.adoc @@ -272,7 +272,7 @@ public class Application { Currently, S3 is the only storage module that supports this experimental API. -=== Content Properties +== Content Properties As we can see above content is "associated" by adding additional metadata about the content to the Entity. This additional metadata is annotated with Spring Content annotations. There are several. The only mandatory annotation is `@ContentId`. Other optional annotations include `@ContentLength`, `@MimeType` and `@OriginalFileName`. These may be added to your entities when you need to capture this additional infomation about your associated content. @@ -389,7 +389,7 @@ store.setContent(user, PropertyPath.from("images/avatar"), avatarStream); ==== [[content-repositories.multimodule]] -=== Using Stores with Multiple Spring Content Storage Modules +== Using Stores with Multiple Spring Content Storage Modules Using a single Spring Content storage module in your application keeps things simple because all Storage beans will use to that one Spring Content storage module as their implementation. Sometimes, applications require @@ -398,7 +398,7 @@ more than one Spring Content storage module. In such cases, a store definition See <> for the signature types for the storage modules you are using. -==== Manual Storage Override +=== Manual Storage Override Because Spring Content provides an abstraction over storage it is also common to use one storage module for testing but another for production. For these cases it is possible to again include multiple Spring Content storage modules, @@ -406,7 +406,7 @@ but use generic Store interfaces, rather than signature types, and instead speci property to manually set the storage implementation to be injected into your Storage beans. [[content-repositories.events]] -=== Events +== Events Spring Content emits twelve events. Roughly speaking one for each Store method. They are: @@ -434,7 +434,7 @@ Spring Content emits twelve events. Roughly speaking one for each Store method. * AfterUnsetContent -==== Writing an ApplicationListener +=== Writing an ApplicationListener If you wish to extend Spring Content's functionality you can subclass the abstract class `AbstractStoreEventListener` and override the methods that you are interested in. When these events occur your handlers will be called. @@ -464,7 +464,7 @@ public class ExampleEventListener extends AbstractStoreEventListener { The down-side of this approach is that it does not filter events based on Entity. -==== Writing an Annotated StoreEventHandler +=== Writing an Annotated StoreEventHandler Another approach is to use an annotated handler, which does filter events based on Entity. @@ -559,7 +559,7 @@ public class ContentStoreConfiguration { ==== [[content-repositories.search]] -=== Searchable Stores +== Searchable Stores Applications that handle documents and other media usually have search capabilities allowing relevant content to be found by looking inside of it for keywords or phrases, so called full-text search. @@ -596,7 +596,7 @@ For `search` to return actual results full-text indexing must be enabled. See < for more information on how to do this. [[content-repositories.renditions]] -=== Renderable Stores +== Renderable Stores Applications that handle files and other media usually also have rendition capabilities allowing content to be transformed from one format to another. @@ -617,6 +617,32 @@ Returns a `mimeType` rendition of the content associated with `entity`. Renditions must be enabled and renderers provided. See <> for more information on how to do this. +[[content-repositories.exceptions]] +== Error Translation + +When using Stores, you must decide how to handle the storage technology’s native exception classes. Typically, storage layers throw runtime exceptions and do not have to be declared or caught. You may also have to deal with `IllegalArgumentException` and `IllegalStateException`. This means that callers can only treat exceptions as being generally fatal, unless they want to depend on the storage technology’s own exception structure. This trade-off might be acceptable to applications that are strongly aligned to a particular storage or do not need any special exception treatment (or both). However, Spring Content lets exception translation be applied transparently through the @Store annotations. The following examples show how to contribute a bean that implements `StoreExceptionTranslator` that translates RuntimeException's to StoreAccessExceptions: + +.StoreExceptionTranslator interface +==== +[source,java] +---- +@Configuration +public class Config { + + @Bean + public StoreExceptionTranslator translator() { + return new StoreExceptionTranslator() { + @Override + public StoreAccessException translate(RuntimeException re) { + ... + } + }; + } + InputStream getRendition(E entity, String mimeType); +} +---- +==== + [[content-repositories.creation]] == Creating Content Store Instances To use these core concepts: @@ -707,132 +733,3 @@ public class SomeClass { ---- <1> Spring Content will update the `@ContentId` and `@ContentLength` fields ==== - -== Patterns of Content Association - -Content can be associated with a Spring Data Entity in several ways. - -=== Entity Association - -The simplest, allowing you to associate one Entity with one Resource, is to decorate your Spring Data Entity with the Spring Content attributes. - -The following example shows a Resource associated with an Entity `Dvd`. - -==== -[source, java] ----- -@Entity -public class Dvd { - private @Id @GeneratedValue Long id; - private String title; - - // Spring Content managed attributes - private @ContentId UUID contentId; - private @ContentLength Long contentLen; - - ... -} - -public interface DvdRepository extends CrudRepository {} - -public interface DvdStore extends ContentStore {} ----- -==== - -=== Property Association - -Sometimes you might want to associate multiple different Resources with an Entity. To do this it is also possible to associate Resources with one or more Entity properties. - -The following example shows two Resources associated with a `Dvd` entity. The first Resource is the Dvd's cover Image and the second is the Dvd's Stream. - -==== -[source, java] ----- -@Entity -public class Dvd { - private @Id @GeneratedValue Long id; - private String title; - - @OneToOne(cascade = CascadeType.ALL) - @JoinColumn(name = "image_id") - private Image image; - - @OneToOne(cascade = CascadeType.ALL) - @JoinColumn(name = "stream_id") - private Stream stream; - - ... -} - -@Entity -public class Image { - // Spring Data managed attribute - private @Id @GeneratedValue Long id; - - @OneToOne - private Dvd dvd; - - // Spring Content managed attributes - private @ContentId UUID contentId; - private @ContentLength Long contentLen; -} - -@Entity -public class Stream { - // Spring Data managed attribute - private @Id @GeneratedValue Long id; - - @OneToOne - private Dvd dvd; - - // Spring Content managed attributes - private @ContentId UUID contentId; - private @ContentLength Long contentLen; -} - -public interface DvdRepository extends CrudRepository {} - -public interface ImageStore extends ContentStore {} - -public interface StreamStore extends ContentStore {} ----- -==== - -Note how the Content attributes are placed on each property object of on the Entity itself. - -When using JPA with a relational database these are typically (but not always) also Entity associations. However when using NoSQL databases like MongoDB that are capable of storing hierarchical data they are true property associations. - -==== Property Collection Associations - -In addition to associating many different types of Resource with a single Entity. It is also possible to associate one Entity with many Resources using a `java.util.Collection` property, as the following example shows. - -==== -[source, java] ----- -@Entity -public class Dvd { - private @Id @GeneratedValue Long id; - private String title; - - @OneToMany - @JoinColumn(name = "chapter_id") - private List chapters; - - ... -} - -@Entity -public class Chapter { - // Spring Data managed attribute - private @Id @GeneratedValue Long id; - - // Spring Content managed attributes - private @ContentId UUID contentId; - private @ContentLength Long contentLen; -} - -public interface DvdRepository extends CrudRepository {} - -public interface ChapterStore extends ContentStore {} ----- -==== diff --git a/spring-content-commons/src/main/java/internal/org/springframework/content/commons/store/factory/StoreExceptionTranslatorInterceptor.java b/spring-content-commons/src/main/java/internal/org/springframework/content/commons/store/factory/StoreExceptionTranslatorInterceptor.java new file mode 100644 index 000000000..368d42523 --- /dev/null +++ b/spring-content-commons/src/main/java/internal/org/springframework/content/commons/store/factory/StoreExceptionTranslatorInterceptor.java @@ -0,0 +1,39 @@ +package internal.org.springframework.content.commons.store.factory; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.content.commons.store.StoreAccessException; +import org.springframework.content.commons.store.StoreExceptionTranslator; + +import java.util.ArrayList; +import java.util.List; + +public class StoreExceptionTranslatorInterceptor implements MethodInterceptor { + private final BeanFactory beanFactory; + private List translators = null; + + public StoreExceptionTranslatorInterceptor(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Override + public Object invoke(MethodInvocation invocation) throws Throwable { + try { + return invocation.proceed(); + } catch (RuntimeException re) { + if (re instanceof StoreAccessException) { + throw re; + } + if (translators == null) { + translators = new ArrayList<>(); + beanFactory.getBeanProvider(StoreExceptionTranslator.class).orderedStream().forEach(translators::add); + } + StoreAccessException sae = null; + for (int i=0; i < translators.size() && sae == null; i++) { + sae = translators.get(i).translate(re); + } + throw sae != null ? sae : re; + } + } +} diff --git a/spring-content-commons/src/main/java/org/springframework/content/commons/repository/factory/AbstractStoreFactoryBean.java b/spring-content-commons/src/main/java/org/springframework/content/commons/repository/factory/AbstractStoreFactoryBean.java index 4037e5669..3138f5c1c 100644 --- a/spring-content-commons/src/main/java/org/springframework/content/commons/repository/factory/AbstractStoreFactoryBean.java +++ b/spring-content-commons/src/main/java/org/springframework/content/commons/repository/factory/AbstractStoreFactoryBean.java @@ -10,7 +10,7 @@ import java.util.Map; import java.util.Set; -import internal.org.springframework.content.commons.store.factory.StoreFactory; +import internal.org.springframework.content.commons.store.factory.*; import org.apache.commons.lang.ClassUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -33,9 +33,6 @@ import internal.org.springframework.content.commons.config.StoreFragment; import internal.org.springframework.content.commons.config.StoreFragments; -import internal.org.springframework.content.commons.store.factory.ReactiveStoreImpl; -import internal.org.springframework.content.commons.store.factory.StoreImpl; -import internal.org.springframework.content.commons.store.factory.StoreMethodInterceptor; /** * @deprecated This class is deprecated. Use {@link org.springframework.content.commons.store.factory.AbstractStoreFactoryBean} instead. @@ -225,6 +222,7 @@ protected Store createContentStore() { } intercepter.setStoreFragments(storeFragments); + result.addAdvice(new StoreExceptionTranslatorInterceptor(beanFactory)); result.addAdvice(intercepter); return (Store) result.getProxy(classLoader); diff --git a/spring-content-commons/src/main/java/org/springframework/content/commons/store/StoreExceptionTranslator.java b/spring-content-commons/src/main/java/org/springframework/content/commons/store/StoreExceptionTranslator.java new file mode 100644 index 000000000..df5a4191a --- /dev/null +++ b/spring-content-commons/src/main/java/org/springframework/content/commons/store/StoreExceptionTranslator.java @@ -0,0 +1,6 @@ +package org.springframework.content.commons.store; + +@FunctionalInterface +public interface StoreExceptionTranslator { + StoreAccessException translate(RuntimeException re); +} diff --git a/spring-content-commons/src/test/java/it/StoresIT.java b/spring-content-commons/src/test/java/it/StoresIT.java index 2f25099cc..52c8006dd 100644 --- a/spring-content-commons/src/test/java/it/StoresIT.java +++ b/spring-content-commons/src/test/java/it/StoresIT.java @@ -1,15 +1,14 @@ package it; -import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.BeforeEach; -import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.Context; -import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.Describe; -import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.It; -import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.JustBeforeEach; +import static com.github.paulcwarren.ginkgo4j.Ginkgo4jDSL.*; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.isA; import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.List; @@ -19,6 +18,9 @@ import internal.org.springframework.content.commons.store.factory.StoreFactory; import org.springframework.content.commons.repository.factory.testsupport.TestContentStore; import org.springframework.content.commons.repository.factory.testsupport.TestStoreFactoryBean; +import org.springframework.content.commons.store.ContentStore; +import org.springframework.content.commons.store.StoreAccessException; +import org.springframework.content.commons.store.StoreExceptionTranslator; import org.springframework.content.commons.storeservice.StoreFilter; import org.springframework.content.commons.storeservice.StoreInfo; import org.springframework.content.commons.storeservice.StoreResolver; @@ -133,8 +135,81 @@ public boolean matches(StoreInfo info) { }); }); }); + + Describe("StoreExceptionTranslatorInterceptor", ( )-> { + BeforeEach(() -> { + // All TestContentStore methods throw an UnsupportedOperationException, this test relies on this + TestStoreFactoryBean factory = new TestStoreFactoryBean(RuntimeExceptionThrowingStore.class); + factory.setBeanClassLoader(this.getClass().getClassLoader()); + factories.add(factory); + + context = new GenericApplicationContext(); + context.registerBean("factory", StoreFactory.class, () -> {return factory;}); + }); + + Context("given there is no store exception translator registered", () -> { + JustBeforeEach(() -> { + context.refresh(); + stores = new StoresImpl(context); + stores.afterPropertiesSet(); + }); + It("should re-throw RuntimeException as StoreAccessException", () -> { + StoreInfo storeInfo = stores.getStore(Store.class, new StoreFilter() { + @Override + public String name() { + return "test"; + } + + @Override + public boolean matches(StoreInfo info) { + return true; + } + }); + ContentStore store = storeInfo.getImplementation(ContentStore.class); + try { + store.setContent(new Object(), new ByteArrayInputStream("".getBytes())); + } catch (Exception e) { + assertThat(e, isA(UnsupportedOperationException.class)); + } + }); + }); + + Context("given there is a store exception translator registered", () -> { + JustBeforeEach(() -> { + context.registerBean("translator", StoreExceptionTranslator.class, () -> {return new StoreExceptionTranslator() { + @Override + public StoreAccessException translate(RuntimeException re) { + return new StoreAccessException(re.getMessage(), re); + } + };}); + context.refresh(); + stores = new StoresImpl(context); + stores.afterPropertiesSet(); + }); + It("should re-throw RuntimeException as StoreAccessException", () -> { + StoreInfo storeInfo = stores.getStore(Store.class, new StoreFilter() { + @Override + public String name() { + return "test"; + } + + @Override + public boolean matches(StoreInfo info) { + return true; + } + }); + ContentStore store = storeInfo.getImplementation(ContentStore.class); + try { + store.setContent(new Object(), new ByteArrayInputStream("".getBytes())); + } catch (Exception e) { + assertThat(e, isA(StoreAccessException.class)); + } + }); + }); + }); } public interface RightStore extends TestContentStore{}; public interface WrongStore extends TestContentStore{}; + public interface RuntimeExceptionThrowingStore extends TestContentStore{}; } diff --git a/spring-content-fs/src/main/java/internal/org/springframework/content/fs/store/DefaultFilesystemStoreImpl.java b/spring-content-fs/src/main/java/internal/org/springframework/content/fs/store/DefaultFilesystemStoreImpl.java index 766861086..cf1ed9dea 100644 --- a/spring-content-fs/src/main/java/internal/org/springframework/content/fs/store/DefaultFilesystemStoreImpl.java +++ b/spring-content-fs/src/main/java/internal/org/springframework/content/fs/store/DefaultFilesystemStoreImpl.java @@ -1,24 +1,8 @@ package internal.org.springframework.content.fs.store; -import static java.lang.String.format; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.Serializable; -import java.lang.annotation.Annotation; -import java.lang.reflect.Field; -import java.lang.reflect.Type; -import java.util.UUID; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - import org.apache.commons.io.IOUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.springframework.beans.BeanWrapper; -import org.springframework.beans.BeanWrapperImpl; import org.springframework.content.commons.annotations.ContentId; import org.springframework.content.commons.annotations.ContentLength; import org.springframework.content.commons.io.DeletableResource; @@ -44,6 +28,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; +import java.io.*; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.UUID; + +import static java.lang.String.format; + @Transactional(readOnly = true) public class DefaultFilesystemStoreImpl implements Store, AssociativeStore, ContentStore, @@ -92,7 +83,6 @@ public Resource getResource(S entity) { return null; } - @Override public Resource getResource(S entity, PropertyPath propertyPath) { return this.getResource(entity, propertyPath, GetResourceParams.builder().build()); @@ -206,12 +196,10 @@ public S setContent(S entity, InputStream content) { os = ((WritableResource) resource).getOutputStream(); IOUtils.copy(content, os); } + } catch (IOException e) { logger.error(format("Unexpected io error setting content for entity %s", entity), e); throw new StoreAccessException(format("Setting content for entity %s", entity), e); - } catch (Exception e) { - logger.error(format("Unexpected error setting content for entity %s", entity), e); - throw new StoreAccessException(format("Setting content for entity %s", entity), e); } finally { IOUtils.closeQuietly(os); @@ -292,9 +280,6 @@ public S setContent(S property, PropertyPath propertyPath, InputStream content, } catch (IOException e) { logger.error(format("Unexpected io error setting content for entity %s", property), e); throw new StoreAccessException(format("Setting content for entity %s", property), e); - } catch (Exception e) { - logger.error(format("Unexpected error setting content for entity %s", property), e); - throw new StoreAccessException(format("Setting content for entity %s", property), e); } finally { IOUtils.closeQuietly(os);