diff --git a/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedApplicationConfig.java b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedApplicationConfig.java new file mode 100644 index 00000000..7252a4d1 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedApplicationConfig.java @@ -0,0 +1,17 @@ +package org.springframework.data.couchbase.repository.auditing; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.couchbase.IntegrationTestApplicationConfig; +import org.springframework.data.couchbase.repository.config.EnableCouchbaseRepositories; + +@Configuration +@EnableCouchbaseRepositories +@EnableCouchbaseAuditing(modifyOnCreate = false) +public class AuditedApplicationConfig extends IntegrationTestApplicationConfig { + + @Bean + public AuditedAuditorAware couchbaseAuditorAware() { + return new AuditedAuditorAware(); + } +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedAuditorAware.java b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedAuditorAware.java new file mode 100644 index 00000000..07812561 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedAuditorAware.java @@ -0,0 +1,18 @@ +package org.springframework.data.couchbase.repository.auditing; + +import org.springframework.data.domain.AuditorAware; +import org.springframework.stereotype.Component; + +public class AuditedAuditorAware implements AuditorAware { + + private String auditor = "auditor"; + + @Override + public String getCurrentAuditor() { + return auditor; + } + + public void setAuditor(String auditor) { + this.auditor = auditor; + } +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedItem.java b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedItem.java new file mode 100644 index 00000000..6b347631 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedItem.java @@ -0,0 +1,129 @@ +package org.springframework.data.couchbase.repository.auditing; + +import java.util.Date; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; + +@Document +public class AuditedItem { + + @Id + private final String id; + + private String value; + + @CreatedBy + private String creator; + + @LastModifiedBy + private String lastModifiedBy; + + @LastModifiedDate + private Date lastModification; + + @CreatedDate + private Date creationDate; + + @Version + private long version; + + public AuditedItem(String id, String value) { + this.id = id; + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getCreator() { + return creator; + } + + public void setCreator(String creator) { + this.creator = creator; + } + + public String getLastModifiedBy() { + return lastModifiedBy; + } + + public void setLastModifiedBy(String lastModifiedBy) { + this.lastModifiedBy = lastModifiedBy; + } + + public Date getLastModification() { + return lastModification; + } + + public void setLastModification(Date lastModification) { + this.lastModification = lastModification; + } + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(Date creationDate) { + this.creationDate = creationDate; + } + + public long getVersion() { + return version; + } + + public void setVersion(long version) { + this.version = version; + } + + @Override + public String toString() { + return "AuditedItem{" + + "id='" + id + '\'' + + ", value='" + value + '\'' + + ", creator='" + creator + '\'' + + ", lastModifiedBy='" + lastModifiedBy + '\'' + + ", lastModification=" + lastModification + + ", creationDate=" + creationDate + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AuditedItem that = (AuditedItem) o; + + if (!id.equals(that.id)) return false; + if (!value.equals(that.value)) return false; + if (creator != null ? !creator.equals(that.creator) : that.creator != null) return false; + if (lastModifiedBy != null ? !lastModifiedBy.equals(that.lastModifiedBy) : that.lastModifiedBy != null) + return false; + if (lastModification != null ? !lastModification.equals(that.lastModification) : that.lastModification != null) + return false; + return creationDate != null ? creationDate.equals(that.creationDate) : that.creationDate == null; + + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + value.hashCode(); + result = 31 * result + (creator != null ? creator.hashCode() : 0); + result = 31 * result + (lastModifiedBy != null ? lastModifiedBy.hashCode() : 0); + result = 31 * result + (lastModification != null ? lastModification.hashCode() : 0); + result = 31 * result + (creationDate != null ? creationDate.hashCode() : 0); + return result; + } +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedRepository.java b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedRepository.java new file mode 100644 index 00000000..b9c87d54 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditedRepository.java @@ -0,0 +1,7 @@ +package org.springframework.data.couchbase.repository.auditing; + +import org.springframework.data.couchbase.repository.CouchbaseRepository; + +public interface AuditedRepository extends CouchbaseRepository { + +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditingTests.java b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditingTests.java new file mode 100644 index 00000000..22d63e04 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/auditing/AuditingTests.java @@ -0,0 +1,89 @@ +package org.springframework.data.couchbase.repository.auditing; + +import static org.junit.Assert.*; + +import java.util.Date; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.couchbase.repository.CouchbaseRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author Simon Baslé + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = AuditedApplicationConfig.class) +public class AuditingTests { + + @Autowired + private AuditedRepository repository; + + @Autowired + private AuditedAuditorAware auditorAware; + + private static final String KEY = "auditedTest"; + + @After + public void cleanupAuditedEntity() { + repository.getCouchbaseOperations().getCouchbaseBucket().remove(KEY); + } + + @Test + public void testCreationEventIsRegistered() { + assertFalse(repository.exists(KEY)); + Date start = new Date(); + AuditedItem item = new AuditedItem(KEY, "creation"); + + auditorAware.setAuditor("auditor"); + repository.save(item); + AuditedItem persisted = repository.findOne(KEY); + + assertNotNull("expected entity to be persisted", persisted); + assertNotNull("expected creation date audit trail", persisted.getCreationDate()); + assertEquals("expected creation user audit trail", "auditor", persisted.getCreator()); + + assertTrue("creation date is too early", persisted.getCreationDate().after(start)); + assertTrue("creation date is too late", persisted.getCreationDate().before(new Date())); + + assertNull("expected modification date to be empty", persisted.getLastModification()); + assertNull("expected modification user to be empty", persisted.getLastModifiedBy()); + + assertNotNull("expected version to be non null", persisted.getVersion()); + assertTrue("expected version to be greater than 0", persisted.getVersion() > 0L); + } + + @Test + public void testUpdateEventIsRegistered() { + assertFalse(repository.exists(KEY)); + + String expectedCreator = "user1"; + String expectedUpdater = "user2"; + AuditedItem item = new AuditedItem(KEY, "creation"); + auditorAware.setAuditor(expectedCreator); + + repository.save(item); + AuditedItem created = repository.findOne(KEY); + + auditorAware.setAuditor(expectedUpdater); + repository.save(item); + AuditedItem updated = repository.findOne(KEY); + + assertNotNull("expected entity to be persisted", updated); + assertNotNull("expected creation date audit trail", updated.getCreationDate()); + assertEquals("expected creation user audit trail", expectedCreator, updated.getCreator()); + + assertNotNull("expected modification date audit trail", updated.getLastModification()); + assertTrue("expected modification date to be after creation date", updated.getCreationDate().before(updated.getLastModification())); + assertEquals("expected modification user to be the modifier", expectedUpdater, updated.getLastModifiedBy()); + + assertNotNull("expected version to be non null", updated.getVersion()); + assertTrue("expected version to be greater than 0", updated.getVersion() > 0L); + assertTrue("expected updated version to be different from the one at creation", created.getVersion() != updated.getVersion()); + } +} diff --git a/src/main/asciidoc/entity.adoc b/src/main/asciidoc/entity.adoc index cdca22b3..8b5d08f4 100644 --- a/src/main/asciidoc/entity.adoc +++ b/src/main/asciidoc/entity.adoc @@ -386,3 +386,88 @@ Now you can annotate your fields with JSR303 annotations. If a validation on `sa private String name; ---- ==== + +[[auditing]] +== Auditing +Entities can be automatically audited (tracing which user created the object, updated the object, and at what times) through Spring Data auditing mechanisms. + +First, note that only entities that have a `@Version` annotated field can be audited for creation (otherwise the framework will interpret a creation as an update). + +Auditing works by annotating fields with `@CreatedBy`, `@CreatedDate`, `@LastModifiedBy` and `@LastModifiedDate`. The framework will automatically inject the correct values on those fields when persisting the entity. The xxxDate annotations must be put on a `Date` field (or compatible, eg. jodatime classes) while the xxxBy annotations can be put on fields of any class `T` (albeit both fields must be of the same type). + +To configure auditing, first you need to have an auditor aware bean in the context. Said bean must be of type `AuditorAware` (allowing to produce a value that can be stored in the xxxBy fields of type `T` we saw earlier). Secondly, you must activate auditing in your `@Configuration` class by using the `@EnableCouchbaseAuditing` annotation. + +Here is an example: + +.Sample Auditing Entity +==== +[source,java] +---- +@Document +public class AuditedItem { + + @Id + private final String id; + + private String value; + + @CreatedBy + private String creator; + + @LastModifiedBy + private String lastModifiedBy; + + @LastModifiedDate + private Date lastModification; + + @CreatedDate + private Date creationDate; + + @Version + private long version; + + //..omitted constructor/getters/setters/... +} +---- +==== +Notice both `@CreatedBy` and `@LastModifiedBy` are both put on a `String` field, so our `AuditorAware` must work with `String`. + +.Sample AuditorAware implementation +==== +[source,java] +---- +public class NaiveAuditorAware implements AuditorAware { + + private String auditor = "auditor"; + + @Override + public String getCurrentAuditor() { + return auditor; + } + + public void setAuditor(String auditor) { + this.auditor = auditor; + } +} +---- +==== + +To tie all that together, we use the java configuration both to declare an AuditorAware bean and to activate auditing: + +.Sample Auditing Configuration +==== +[source,java] +---- +@Configuration +@EnableCouchbaseAuditing //this activates auditing +public class AuditConfiguration extends AbstractCouchbaseConfiguration { + + //... a few abstract methods omitted here + + // this creates the auditor aware bean that will feed the annotations + @Bean + public NaiveAuditorAware testAuditorAware() { + return new NaiveAuditorAware(); + } +---- +==== \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/config/BeanNames.java b/src/main/java/org/springframework/data/couchbase/config/BeanNames.java index 5b1ceaff..2b5591dc 100644 --- a/src/main/java/org/springframework/data/couchbase/config/BeanNames.java +++ b/src/main/java/org/springframework/data/couchbase/config/BeanNames.java @@ -24,6 +24,7 @@ import com.couchbase.client.java.env.CouchbaseEnvironment; import org.springframework.core.convert.converter.Converter; import org.springframework.data.couchbase.core.CouchbaseOperations; import org.springframework.data.couchbase.core.convert.translation.TranslationService; +import org.springframework.data.support.IsNewStrategyFactory; /** * Contains default bean names for Couchbase beans. @@ -108,4 +109,9 @@ public class BeanNames { * The name for the bean that registers custom {@link Converter Converters} to encode/decode entity members. */ public static final String COUCHBASE_CUSTOM_CONVERSIONS = "couchbaseCustomConversions"; + + /** + * The name for the bean that will handle audit trail marking of entities. + */ + public static final String COUCHBASE_AUDITING_HANDLER = "couchbaseAuditingHandler"; } diff --git a/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEventListener.java b/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEventListener.java new file mode 100644 index 00000000..abe5cf0e --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/mapping/event/AuditingEventListener.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2016 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.couchbase.core.mapping.event; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.context.ApplicationListener; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.util.Assert; + +/** + * Event listener to populate auditing related fields on an entity about to be saved. + * + * @author Oliver Gierke + * @author Simon Baslé + */ +public class AuditingEventListener implements ApplicationListener> { + + private final ObjectFactory auditingHandlerFactory; + + /** + * Creates a new {@link AuditingEventListener} using the given {@link MappingContext} and {@link AuditingHandler} + * provided by the given {@link ObjectFactory}. + * + * @param auditingHandlerFactory must not be {@literal null}. + */ + public AuditingEventListener(ObjectFactory auditingHandlerFactory) { + Assert.notNull(auditingHandlerFactory, "IsNewAwareAuditingHandler must not be null!"); + this.auditingHandlerFactory = auditingHandlerFactory; + } + + /* + * (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + public void onApplicationEvent(BeforeConvertEvent event) { + + Object entity = event.getSource(); + auditingHandlerFactory.getObject().markAudited(entity); + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java b/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java new file mode 100644 index 00000000..5c7107e0 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/auditing/CouchbaseAuditingRegistrar.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-2016 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.couchbase.repository.auditing; + +import java.lang.annotation.Annotation; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.auditing.IsNewAwareAuditingHandler; +import org.springframework.data.auditing.config.AuditingBeanDefinitionRegistrarSupport; +import org.springframework.data.auditing.config.AuditingConfiguration; +import org.springframework.data.config.ParsingUtils; +import org.springframework.data.couchbase.config.BeanNames; +import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; +import org.springframework.data.couchbase.core.mapping.event.AuditingEventListener; +import org.springframework.data.support.IsNewStrategyFactory; +import org.springframework.util.Assert; + +/** + * A support registrar that allows to set up auditing for Couchbase (including {@link AuditingHandler} + * and {@link IsNewStrategyFactory} set up). See {@link EnableCouchbaseAuditing} for the associated annotation. + * + * @author Thomas Darimont + * @author Oliver Gierke + * @author Simon Baslé + */ +public class CouchbaseAuditingRegistrar extends AuditingBeanDefinitionRegistrarSupport { + + @Override + protected Class getAnnotation() { + return EnableCouchbaseAuditing.class; + } + + @Override + protected String getAuditingHandlerBeanName() { + return BeanNames.COUCHBASE_AUDITING_HANDLER; + } + + @Override + public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry registry) { + Assert.notNull(annotationMetadata, "AnnotationMetadata must not be null!"); + Assert.notNull(registry, "BeanDefinitionRegistry must not be null!"); + + ensureMappingContext(registry, annotationMetadata); + super.registerBeanDefinitions(annotationMetadata, registry); + } + + @Override + protected BeanDefinitionBuilder getAuditHandlerBeanDefinitionBuilder(AuditingConfiguration configuration) { + Assert.notNull(configuration, "AuditingConfiguration must not be null!"); + + BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(IsNewAwareAuditingHandler.class); + builder.addConstructorArgReference(BeanNames.COUCHBASE_MAPPING_CONTEXT); + return configureDefaultAuditHandlerAttributes(configuration, builder); + } + + @Override + protected void registerAuditListenerBeanDefinition(BeanDefinition auditingHandlerDefinition, + BeanDefinitionRegistry registry) { + Assert.notNull(auditingHandlerDefinition, "BeanDefinition must not be null!"); + Assert.notNull(registry, "BeanDefinitionRegistry must not be null!"); + + BeanDefinitionBuilder listenerBeanDefinitionBuilder = BeanDefinitionBuilder + .rootBeanDefinition(AuditingEventListener.class); + listenerBeanDefinitionBuilder.addConstructorArgValue(ParsingUtils.getObjectFactoryBeanDefinition( + getAuditingHandlerBeanName(), registry)); + + registerInfrastructureBeanWithId(listenerBeanDefinitionBuilder.getBeanDefinition(), + AuditingEventListener.class.getName(), registry); + } + + private void ensureMappingContext(BeanDefinitionRegistry registry, Object source) { + if (!registry.containsBeanDefinition(BeanNames.COUCHBASE_MAPPING_CONTEXT)) { + RootBeanDefinition definition = new RootBeanDefinition(CouchbaseMappingContext.class); + definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + definition.setSource(source); + + registry.registerBeanDefinition(BeanNames.COUCHBASE_MAPPING_CONTEXT, definition); + } + } +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/auditing/EnableCouchbaseAuditing.java b/src/main/java/org/springframework/data/couchbase/repository/auditing/EnableCouchbaseAuditing.java new file mode 100644 index 00000000..49e6eb32 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/auditing/EnableCouchbaseAuditing.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2016 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.couchbase.repository.auditing; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.domain.AuditorAware; + +/** + * Annotation to enable auditing in Couchbase via annotation configuration. + * + * @author Thomas Darimont + * @author Oliver Gierke + * @author Simon Baslé + */ +@Inherited +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(CouchbaseAuditingRegistrar.class) +public @interface EnableCouchbaseAuditing { + /** + * Configures the {@link AuditorAware} bean to be used to lookup the current principal. + */ + String auditorAwareRef() default ""; + + /** + * Configures whether the creation and modification dates are set. Defaults to {@literal true}. + */ + boolean setDates() default true; + + /** + * Configures whether the entity shall be marked as modified on creation. Defaults to {@literal true}. + */ + boolean modifyOnCreate() default true; + + /** + * Configures a {@link DateTimeProvider} bean name that allows customizing the {@link org.joda.time.DateTime} to be + * used for setting creation and modification dates. + */ + String dateTimeProviderRef() default ""; +}