diff --git a/arconia-core/.gitignore b/arconia-core/.gitignore deleted file mode 100644 index c2065bc..0000000 --- a/arconia-core/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListener.java b/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListener.java index 0bd213f..3e7fdb4 100644 --- a/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListener.java +++ b/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListener.java @@ -14,7 +14,7 @@ */ public final class MdcTenantContextEventListener implements TenantEventListener { - private static final String DEFAULT_TENANT_ID_KEY = "tenantId"; + public static final String DEFAULT_TENANT_ID_KEY = "tenantId"; private final String tenantIdKey; diff --git a/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListener.java b/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListener.java index 95d9668..50955f5 100644 --- a/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListener.java +++ b/arconia-core/src/main/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListener.java @@ -25,14 +25,14 @@ public class ObservationTenantContextEventListener implements TenantEventListene private final String tenantIdKey; public ObservationTenantContextEventListener() { - this(DEFAULT_CARDINALITY, DEFAULT_TENANT_ID_KEY); + this(DEFAULT_TENANT_ID_KEY, DEFAULT_CARDINALITY); } - public ObservationTenantContextEventListener(Cardinality cardinality, String tenantIdKey) { - Assert.notNull(cardinality, "cardinality cannot be null"); + public ObservationTenantContextEventListener(String tenantIdKey, Cardinality cardinality) { Assert.hasText(tenantIdKey, "tenantIdKey cannot be empty"); - this.cardinality = cardinality; + Assert.notNull(cardinality, "cardinality cannot be null"); this.tenantIdKey = tenantIdKey; + this.cardinality = cardinality; } @Override diff --git a/arconia-core/src/main/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolver.java b/arconia-core/src/main/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolver.java index 706189f..0b944c9 100644 --- a/arconia-core/src/main/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolver.java +++ b/arconia-core/src/main/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolver.java @@ -10,7 +10,7 @@ */ public final class FixedTenantResolver implements TenantResolver { - private static final String DEFAULT_FIXED_TENANT = "default"; + public static final String DEFAULT_FIXED_TENANT = "default"; private final String fixedTenantName; diff --git a/arconia-core/src/main/java/io/arconia/core/multitenancy/exceptions/TenantRequiredException.java b/arconia-core/src/main/java/io/arconia/core/multitenancy/exceptions/TenantResolutionException.java similarity index 62% rename from arconia-core/src/main/java/io/arconia/core/multitenancy/exceptions/TenantRequiredException.java rename to arconia-core/src/main/java/io/arconia/core/multitenancy/exceptions/TenantResolutionException.java index 71fe8b8..a4be8be 100644 --- a/arconia-core/src/main/java/io/arconia/core/multitenancy/exceptions/TenantRequiredException.java +++ b/arconia-core/src/main/java/io/arconia/core/multitenancy/exceptions/TenantResolutionException.java @@ -5,13 +5,13 @@ * * @author Thomas Vitale */ -public class TenantRequiredException extends IllegalStateException { +public class TenantResolutionException extends IllegalStateException { - public TenantRequiredException() { + public TenantResolutionException() { super("A tenant must be specified for the current operation"); } - public TenantRequiredException(String message) { + public TenantResolutionException(String message) { super(message); } diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/cache/DefaultTenantKeyGeneratorTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/cache/DefaultTenantKeyGeneratorTests.java index 70d065c..ad07024 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/cache/DefaultTenantKeyGeneratorTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/cache/DefaultTenantKeyGeneratorTests.java @@ -9,6 +9,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link DefaultTenantKeyGenerator}. + * + * @author Thomas Vitale + */ class DefaultTenantKeyGeneratorTests { DefaultTenantKeyGenerator keyGenerator = new DefaultTenantKeyGenerator(); diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/TenantContextHolderTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/TenantContextHolderTests.java index c7976f5..dda7f74 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/TenantContextHolderTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/TenantContextHolderTests.java @@ -7,6 +7,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link TenantContextHolder}. + * + * @author Thomas Vitale + */ class TenantContextHolderTests { @Test diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/HolderTenantContextEventListenerTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/HolderTenantContextEventListenerTests.java index df5fa9e..8832c92 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/HolderTenantContextEventListenerTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/HolderTenantContextEventListenerTests.java @@ -6,6 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Unit tests for {@link HolderTenantContextEventListener}. + * + * @author Thomas Vitale + */ class HolderTenantContextEventListenerTests { @Test diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListenerTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListenerTests.java index 754a2b9..dfb6276 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListenerTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/MdcTenantContextEventListenerTests.java @@ -6,6 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link MdcTenantContextEventListener}. + * + * @author Thomas Vitale + */ class MdcTenantContextEventListenerTests { @Test @@ -50,4 +55,4 @@ void whenCustomValueIsUsedAsKey() { assertThat(MDC.get(tenantKey)).isNull(); } -} \ No newline at end of file +} diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListenerTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListenerTests.java index b03a362..ae5a79f 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListenerTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/events/ObservationTenantContextEventListenerTests.java @@ -8,29 +8,32 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link ObservationTenantContextEventListener}. + * + * @author Thomas Vitale + */ class ObservationTenantContextEventListenerTests { @Test void whenNullCardinalityThenThrow() { - assertThatThrownBy(() -> new ObservationTenantContextEventListener(null, "tenant.id")) + assertThatThrownBy(() -> new ObservationTenantContextEventListener("tenant.id", null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("cardinality cannot be null"); } @Test void whenEmptyTenantKeyThenThrow() { - assertThatThrownBy( - () -> new ObservationTenantContextEventListener(ObservationTenantContextEventListener.Cardinality.HIGH, - "")) + assertThatThrownBy(() -> new ObservationTenantContextEventListener("", + ObservationTenantContextEventListener.Cardinality.HIGH)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("tenantIdKey cannot be empty"); } @Test void whenNullTenantKeyThenThrow() { - assertThatThrownBy( - () -> new ObservationTenantContextEventListener(ObservationTenantContextEventListener.Cardinality.HIGH, - null)) + assertThatThrownBy(() -> new ObservationTenantContextEventListener(null, + ObservationTenantContextEventListener.Cardinality.HIGH)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("tenantIdKey cannot be empty"); } @@ -54,8 +57,8 @@ void whenDefaultValueIsUsedAsKey() { void whenCustomValueIsUsedAsKey() { var tenantKey = "tenant.identifier"; var tenantId = "acme"; - var listener = new ObservationTenantContextEventListener( - ObservationTenantContextEventListener.DEFAULT_CARDINALITY, tenantKey); + var listener = new ObservationTenantContextEventListener(tenantKey, + ObservationTenantContextEventListener.DEFAULT_CARDINALITY); var observationContext = new Observation.Context(); var event = new TenantContextAttachedEvent(tenantId, this); event.setObservationContext(observationContext); @@ -69,8 +72,9 @@ void whenCustomValueIsUsedAsKey() { @Test void whenCustomCardinalityIsUsed() { var tenantId = "acme"; - var listener = new ObservationTenantContextEventListener(ObservationTenantContextEventListener.Cardinality.LOW, - ObservationTenantContextEventListener.DEFAULT_TENANT_ID_KEY); + var listener = new ObservationTenantContextEventListener( + ObservationTenantContextEventListener.DEFAULT_TENANT_ID_KEY, + ObservationTenantContextEventListener.Cardinality.LOW); var observationContext = new Observation.Context(); var event = new TenantContextAttachedEvent(tenantId, this); event.setObservationContext(observationContext); diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolverTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolverTests.java index 50e98a0..85f6162 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolverTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/context/resolvers/FixedTenantResolverTests.java @@ -5,6 +5,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link FixedTenantResolver}. + * + * @author Thomas Vitale + */ class FixedTenantResolverTests { @Test @@ -35,4 +40,4 @@ void whenCustomValueIsUsedAsFixedTenant() { assertThat(actualTenantId).isEqualTo(expectedTenantId); } -} \ No newline at end of file +} diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/events/DefaultTenantEventPublisherTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/events/DefaultTenantEventPublisherTests.java index f123c73..7a315da 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/events/DefaultTenantEventPublisherTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/events/DefaultTenantEventPublisherTests.java @@ -8,6 +8,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link DefaultTenantEventPublisher}. + * + * @author Thomas Vitale + */ class DefaultTenantEventPublisherTests { @Test diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/events/TenantEventTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/events/TenantEventTests.java index 84cedbe..0d18c9d 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/events/TenantEventTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/events/TenantEventTests.java @@ -4,6 +4,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link TenantEvent}. + * + * @author Thomas Vitale + */ class TenantEventTests { @Test diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantNotFoundExceptionTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantNotFoundExceptionTests.java index 02f22de..43a0fe2 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantNotFoundExceptionTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantNotFoundExceptionTests.java @@ -4,6 +4,11 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Unit tests for {@link TenantNotFoundException}. + * + * @author Thomas Vitale + */ class TenantNotFoundExceptionTests { @Test diff --git a/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantRequiredExceptionTests.java b/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantResolutionExceptionTests.java similarity index 65% rename from arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantRequiredExceptionTests.java rename to arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantResolutionExceptionTests.java index 034eedf..6793c21 100644 --- a/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantRequiredExceptionTests.java +++ b/arconia-core/src/test/java/io/arconia/core/multitenancy/exceptions/TenantResolutionExceptionTests.java @@ -4,18 +4,23 @@ import static org.assertj.core.api.Assertions.assertThat; -class TenantRequiredExceptionTests { +/** + * Unit tests for {@link TenantResolutionException}. + * + * @author Thomas Vitale + */ +class TenantResolutionExceptionTests { @Test void whenDefaultMessage() { - var exception = new TenantRequiredException(); + var exception = new TenantResolutionException(); assertThat(exception).hasMessageContaining("A tenant must be specified for the current operation"); } @Test void whenCustomMessage() { var message = "Custom tenant exception message"; - var exception = new TenantRequiredException(message); + var exception = new TenantResolutionException(message); assertThat(exception).hasMessageContaining(message); } diff --git a/arconia-spring-boot-autoconfigure/build.gradle b/arconia-spring-boot-autoconfigure/build.gradle new file mode 100644 index 0000000..8c3cb19 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'code-quality-conventions' + id 'java-conventions' + id 'sbom-conventions' + id 'release-conventions' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +dependencies { + annotationProcessor 'org.springframework.boot:spring-boot-autoconfigure-processor' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + implementation 'org.springframework.boot:spring-boot-starter' + + optional project(':arconia-core') + optional project(':arconia-web') + + optional 'jakarta.servlet:jakarta.servlet-api' + + optional 'org.springframework:spring-context' + optional 'org.springframework:spring-web' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = "Arconia Spring Boot Autoconfigure" + description = "Arconia Spring Boot Autoconfigure." + } + } + } +} diff --git a/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/FixedTenantResolutionProperties.java b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/FixedTenantResolutionProperties.java new file mode 100644 index 0000000..d67bfae --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/FixedTenantResolutionProperties.java @@ -0,0 +1,43 @@ +package io.arconia.autoconfigure.core.multitenancy; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.arconia.core.multitenancy.context.resolvers.FixedTenantResolver; + +/** + * Configuration properties for fixed tenant resolution. + * + * @author Thomas Vitale + */ +@ConfigurationProperties(prefix = FixedTenantResolutionProperties.CONFIG_PREFIX) +public class FixedTenantResolutionProperties { + + public static final String CONFIG_PREFIX = "arconia.multitenancy.resolution.fixed"; + + /** + * Whether a fixed tenant resolution strategy should be used. + */ + private boolean enabled = false; + + /** + * The name of the fixed tenant to use in each context. + */ + private String tenantId = FixedTenantResolver.DEFAULT_FIXED_TENANT; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + +} diff --git a/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/MultitenancyCoreAutoConfiguration.java b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/MultitenancyCoreAutoConfiguration.java new file mode 100644 index 0000000..206bde9 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/MultitenancyCoreAutoConfiguration.java @@ -0,0 +1,72 @@ +package io.arconia.autoconfigure.core.multitenancy; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; + +import io.arconia.core.multitenancy.cache.DefaultTenantKeyGenerator; +import io.arconia.core.multitenancy.cache.TenantKeyGenerator; +import io.arconia.core.multitenancy.context.events.HolderTenantContextEventListener; +import io.arconia.core.multitenancy.context.events.MdcTenantContextEventListener; +import io.arconia.core.multitenancy.context.events.ObservationTenantContextEventListener; +import io.arconia.core.multitenancy.context.resolvers.FixedTenantResolver; +import io.arconia.core.multitenancy.events.DefaultTenantEventPublisher; +import io.arconia.core.multitenancy.events.TenantEventPublisher; + +/** + * Auto-configuration for core multitenancy. + * + * @author Thomas Vitale + */ +@AutoConfiguration +@EnableConfigurationProperties({ FixedTenantResolutionProperties.class, TenantManagementProperties.class }) +public class MultitenancyCoreAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + TenantKeyGenerator tenantKeyGenerator() { + return new DefaultTenantKeyGenerator(); + } + + @Bean + @ConditionalOnMissingBean + HolderTenantContextEventListener holderTenantContextEventListener() { + return new HolderTenantContextEventListener(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = TenantManagementProperties.CONFIG_PREFIX, value = "mdc.enabled", + havingValue = "true", matchIfMissing = true) + MdcTenantContextEventListener mdcTenantContextEventListener(TenantManagementProperties tenantManagementProperties) { + return new MdcTenantContextEventListener(tenantManagementProperties.getMdc().getKey()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = TenantManagementProperties.CONFIG_PREFIX, value = "observations.enabled", + havingValue = "true", matchIfMissing = true) + ObservationTenantContextEventListener observationTenantContextEventListener( + TenantManagementProperties tenantManagementProperties) { + return new ObservationTenantContextEventListener(tenantManagementProperties.getObservations().getKey(), + tenantManagementProperties.getObservations().getCardinality()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = FixedTenantResolutionProperties.CONFIG_PREFIX, value = "enabled", + havingValue = "true") + FixedTenantResolver fixedTenantResolver(FixedTenantResolutionProperties fixedTenantResolutionProperties) { + return new FixedTenantResolver(fixedTenantResolutionProperties.getTenantId()); + } + + @Bean + @ConditionalOnMissingBean + TenantEventPublisher tenantEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + return new DefaultTenantEventPublisher(applicationEventPublisher); + } + +} diff --git a/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/TenantManagementProperties.java b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/TenantManagementProperties.java new file mode 100644 index 0000000..ddae009 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/core/multitenancy/TenantManagementProperties.java @@ -0,0 +1,110 @@ +package io.arconia.autoconfigure.core.multitenancy; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.arconia.core.multitenancy.context.events.MdcTenantContextEventListener; +import io.arconia.core.multitenancy.context.events.ObservationTenantContextEventListener; + +/** + * Configuration properties for tenant management. + * + * @author Thomas Vitale + */ +@ConfigurationProperties(prefix = TenantManagementProperties.CONFIG_PREFIX) +public class TenantManagementProperties { + + public static final String CONFIG_PREFIX = "arconia.multitenancy.management"; + + /** + * Tenant configuration for MDC. + */ + private final Mdc mdc = new Mdc(); + + /** + * Tenant configuration for observations. + */ + private final Observations observations = new Observations(); + + public Mdc getMdc() { + return mdc; + } + + public Observations getObservations() { + return observations; + } + + public static class Mdc { + + /** + * Whether to include tenant information in MDC. + */ + private boolean enabled = true; + + /** + * The key to use for including the tenant identifier information in MDC. + */ + private String key = MdcTenantContextEventListener.DEFAULT_TENANT_ID_KEY; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + } + + public static class Observations { + + /** + * Whether observations are enhanced with tenant information. + */ + private boolean enabled = true; + + /** + * The key to use for including the tenant identifier information in observations. + */ + private String key = ObservationTenantContextEventListener.DEFAULT_TENANT_ID_KEY; + + /** + * Whether to include the tenant identifier information in traces ('high' + * cardinality) or also in metrics ('low' cardinality). + */ + private ObservationTenantContextEventListener.Cardinality cardinality = ObservationTenantContextEventListener.Cardinality.HIGH; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public ObservationTenantContextEventListener.Cardinality getCardinality() { + return cardinality; + } + + public void setCardinality(ObservationTenantContextEventListener.Cardinality cardinality) { + this.cardinality = cardinality; + } + + } + +} diff --git a/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/HttpTenantResolutionConfiguration.java b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/HttpTenantResolutionConfiguration.java new file mode 100644 index 0000000..f526568 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/HttpTenantResolutionConfiguration.java @@ -0,0 +1,77 @@ +package io.arconia.autoconfigure.web.multitenancy; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.arconia.autoconfigure.core.multitenancy.FixedTenantResolutionProperties; +import io.arconia.core.multitenancy.context.resolvers.FixedTenantResolver; +import io.arconia.core.multitenancy.events.TenantEventPublisher; +import io.arconia.web.multitenancy.context.filters.TenantContextFilter; +import io.arconia.web.multitenancy.context.filters.TenantContextIgnorePathMatcher; +import io.arconia.web.multitenancy.context.resolvers.CookieTenantResolver; +import io.arconia.web.multitenancy.context.resolvers.HeaderTenantResolver; +import io.arconia.web.multitenancy.context.resolvers.HttpRequestTenantResolver; + +/** + * Configuration for HTTP tenant resolution. + * + * @author Thomas Vitale + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(HttpTenantResolutionProperties.class) +@ConditionalOnProperty(prefix = HttpTenantResolutionProperties.CONFIG_PREFIX, value = "enabled", havingValue = "true", + matchIfMissing = true) +public class HttpTenantResolutionConfiguration { + + @Bean + @ConditionalOnBean(FixedTenantResolver.class) + @ConditionalOnProperty(prefix = FixedTenantResolutionProperties.CONFIG_PREFIX, value = "enabled", + havingValue = "true") + HttpRequestTenantResolver fixedHttpRequestTenantResolver(FixedTenantResolver fixedTenantResolver) { + return fixedTenantResolver::resolveTenantId; + } + + @Bean + @ConditionalOnMissingBean(HttpRequestTenantResolver.class) + @ConditionalOnProperty(prefix = HttpTenantResolutionProperties.CONFIG_PREFIX, value = "resolution-mode", + havingValue = "header", matchIfMissing = true) + HeaderTenantResolver headerTenantResolver(HttpTenantResolutionProperties httpTenantResolutionProperties) { + return new HeaderTenantResolver(httpTenantResolutionProperties.getHeader().getHeaderName()); + } + + @Bean + @ConditionalOnMissingBean(HttpRequestTenantResolver.class) + @ConditionalOnProperty(prefix = HttpTenantResolutionProperties.CONFIG_PREFIX, value = "resolution-mode", + havingValue = "cookie") + CookieTenantResolver cookieTenantResolver(HttpTenantResolutionProperties httpTenantResolutionProperties) { + return new CookieTenantResolver(httpTenantResolutionProperties.getCookie().getCookieName()); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = HttpTenantResolutionProperties.CONFIG_PREFIX, value = "filter.enabled", + havingValue = "true", matchIfMissing = true) + static class HttpTenantFilterConfiguration { + + @Bean + @ConditionalOnMissingBean + TenantContextFilter tenantContextFilter(HttpRequestTenantResolver httpRequestTenantResolver, + TenantContextIgnorePathMatcher tenantContextIgnorePathMatcher, + TenantEventPublisher tenantEventPublisher) { + return new TenantContextFilter(httpRequestTenantResolver, tenantContextIgnorePathMatcher, + tenantEventPublisher); + } + + @Bean + @ConditionalOnMissingBean + TenantContextIgnorePathMatcher tenantContextIgnorePathMatcher( + HttpTenantResolutionProperties httpTenantResolutionProperties) { + return new TenantContextIgnorePathMatcher(httpTenantResolutionProperties.getFilter().getIgnorePaths()); + } + + } + +} diff --git a/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/HttpTenantResolutionProperties.java b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/HttpTenantResolutionProperties.java new file mode 100644 index 0000000..0f84372 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/HttpTenantResolutionProperties.java @@ -0,0 +1,144 @@ +package io.arconia.autoconfigure.web.multitenancy; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.arconia.web.multitenancy.context.resolvers.CookieTenantResolver; +import io.arconia.web.multitenancy.context.resolvers.HeaderTenantResolver; + +/** + * Configuration properties for HTTP tenant resolution. + * + * @author Thomas Vitale + */ +@ConfigurationProperties(prefix = HttpTenantResolutionProperties.CONFIG_PREFIX) +public class HttpTenantResolutionProperties { + + public static final String CONFIG_PREFIX = "arconia.multitenancy.resolution.http"; + + /** + * Whether an HTTP tenant resolution strategy should be used. + */ + private boolean enabled = true; + + /** + * The HTTP type of resolution. + */ + private HttpResolutionMode type = HttpResolutionMode.HEADER; + + /** + * Configuration for HTTP header tenant resolution. + */ + private final Header header = new Header(); + + /** + * Configuration for HTTP cookie tenant resolution. + */ + private final Cookie cookie = new Cookie(); + + /** + * Configuration for HTTP filter resolving the current tenant. + */ + private final Filter filter = new Filter(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public HttpResolutionMode getType() { + return type; + } + + public void setType(HttpResolutionMode type) { + this.type = type; + } + + public Header getHeader() { + return header; + } + + public Cookie getCookie() { + return cookie; + } + + public Filter getFilter() { + return filter; + } + + public static class Header { + + /** + * The name of the HTTP header from which to resolve the current tenant. + */ + private String headerName = HeaderTenantResolver.DEFAULT_HEADER_NAME; + + public String getHeaderName() { + return headerName; + } + + public void setHeaderName(String headerName) { + this.headerName = headerName; + } + + } + + public static class Cookie { + + /** + * The name of the HTTP cookie from which to resolve the current tenant. + */ + private String cookieName = CookieTenantResolver.DEFAULT_COOKIE_NAME; + + public String getCookieName() { + return cookieName; + } + + public void setCookieName(String cookieName) { + this.cookieName = cookieName; + } + + } + + public static class Filter { + + /** + * Whether the HTTP filter resolving the current tenant is enabled. + */ + private boolean enabled = true; + + /** + * A list of HTTP request paths for which the tenant resolution will not be + * performed. + */ + private List ignorePaths = List.of("/actuator/**", "/webjars/**", "/css/**", "/js/**", ".ico"); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getIgnorePaths() { + return ignorePaths; + } + + public void setIgnorePaths(List ignorePaths) { + this.ignorePaths = ignorePaths; + } + + } + + public enum HttpResolutionMode { + + COOKIE, HEADER + + } + +} diff --git a/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/MultitenancyWebAutoConfiguration.java b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/MultitenancyWebAutoConfiguration.java new file mode 100644 index 0000000..8dbce75 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/java/io/arconia/autoconfigure/web/multitenancy/MultitenancyWebAutoConfiguration.java @@ -0,0 +1,19 @@ +package io.arconia.autoconfigure.web.multitenancy; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Import; + +import io.arconia.autoconfigure.core.multitenancy.MultitenancyCoreAutoConfiguration; + +/** + * Auto-configuration for web multitenancy. + * + * @author Thomas Vitale + */ +@AutoConfiguration(after = MultitenancyCoreAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@Import(HttpTenantResolutionConfiguration.class) +public class MultitenancyWebAutoConfiguration { + +} diff --git a/arconia-spring-boot-autoconfigure/src/main/resources/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/arconia-spring-boot-autoconfigure/src/main/resources/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..229f935 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/main/resources/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +io.arconia.autoconfigure.core.multitenancy.MultitenancyCoreAutoConfiguration +io.arconia.autoconfigure.web.multitenancy.MultitenancyWebAutoConfiguration diff --git a/arconia-spring-boot-autoconfigure/src/test/java/io/arconia/autoconfigure/core/multitenancy/MultitenancyCoreAutoConfigurationTests.java b/arconia-spring-boot-autoconfigure/src/test/java/io/arconia/autoconfigure/core/multitenancy/MultitenancyCoreAutoConfigurationTests.java new file mode 100644 index 0000000..b3794d0 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/test/java/io/arconia/autoconfigure/core/multitenancy/MultitenancyCoreAutoConfigurationTests.java @@ -0,0 +1,101 @@ +package io.arconia.autoconfigure.core.multitenancy; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import io.arconia.core.multitenancy.cache.TenantKeyGenerator; +import io.arconia.core.multitenancy.context.events.HolderTenantContextEventListener; +import io.arconia.core.multitenancy.context.events.MdcTenantContextEventListener; +import io.arconia.core.multitenancy.context.events.ObservationTenantContextEventListener; +import io.arconia.core.multitenancy.context.resolvers.FixedTenantResolver; +import io.arconia.core.multitenancy.events.DefaultTenantEventPublisher; +import io.arconia.core.multitenancy.events.TenantEventPublisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link MultitenancyCoreAutoConfiguration}. + * + * @author Thomas Vitale + */ +class MultitenancyCoreAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MultitenancyCoreAutoConfiguration.class)); + + @Test + void tenantKeyGenerator() { + contextRunner.run(context -> { + var bean = context.getBean(TenantKeyGenerator.class); + assertThat(bean).isInstanceOf(TenantKeyGenerator.class); + }); + } + + @Test + void holderTenantContextEventListener() { + contextRunner.run(context -> { + var bean = context.getBean(HolderTenantContextEventListener.class); + assertThat(bean).isInstanceOf(HolderTenantContextEventListener.class); + }); + } + + @Test + void observationTenantContextEventListenerWhenDefault() { + contextRunner.run(context -> { + var bean = context.getBean(ObservationTenantContextEventListener.class); + assertThat(bean).isInstanceOf(ObservationTenantContextEventListener.class); + }); + } + + @Test + void observationTenantContextEventListenerWhenDisabled() { + contextRunner.withPropertyValues("arconia.multitenancy.management.observations.enabled=false").run(context -> { + assertThatThrownBy(() -> context.getBean(ObservationTenantContextEventListener.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class); + }); + } + + @Test + void mdcTenantContextEventListenerWhenDefault() { + contextRunner.run(context -> { + var bean = context.getBean(MdcTenantContextEventListener.class); + assertThat(bean).isInstanceOf(MdcTenantContextEventListener.class); + }); + } + + @Test + void mdcTenantContextEventListenerWhenDisabled() { + contextRunner.withPropertyValues("arconia.multitenancy.management.mdc.enabled=false").run(context -> { + assertThatThrownBy(() -> context.getBean(MdcTenantContextEventListener.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class); + }); + } + + @Test + void fixedTenantResolverWhenDefault() { + contextRunner.run(context -> { + assertThatThrownBy(() -> context.getBean(FixedTenantResolver.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class); + }); + } + + @Test + void fixedTenantResolverWhenEnabled() { + contextRunner.withPropertyValues("arconia.multitenancy.resolution.fixed.enabled=true").run(context -> { + var bean = context.getBean(FixedTenantResolver.class); + assertThat(bean).isInstanceOf(FixedTenantResolver.class); + }); + } + + @Test + void tenantEventPublisher() { + contextRunner.run(context -> { + var bean = context.getBean(TenantEventPublisher.class); + assertThat(bean).isInstanceOf(DefaultTenantEventPublisher.class); + }); + } + +} diff --git a/arconia-spring-boot-autoconfigure/src/test/java/io/arconia/autoconfigure/web/multitenancy/MultitenancyWebAutoConfigurationTests.java b/arconia-spring-boot-autoconfigure/src/test/java/io/arconia/autoconfigure/web/multitenancy/MultitenancyWebAutoConfigurationTests.java new file mode 100644 index 0000000..bf65763 --- /dev/null +++ b/arconia-spring-boot-autoconfigure/src/test/java/io/arconia/autoconfigure/web/multitenancy/MultitenancyWebAutoConfigurationTests.java @@ -0,0 +1,118 @@ +package io.arconia.autoconfigure.web.multitenancy; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.mock.web.MockHttpServletRequest; + +import io.arconia.autoconfigure.core.multitenancy.MultitenancyCoreAutoConfiguration; +import io.arconia.web.multitenancy.context.filters.TenantContextFilter; +import io.arconia.web.multitenancy.context.filters.TenantContextIgnorePathMatcher; +import io.arconia.web.multitenancy.context.resolvers.CookieTenantResolver; +import io.arconia.web.multitenancy.context.resolvers.HeaderTenantResolver; +import io.arconia.web.multitenancy.context.resolvers.HttpRequestTenantResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link MultitenancyWebAutoConfiguration}. + * + * @author Thomas Vitale + */ +class MultitenancyWebAutoConfigurationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner().withConfiguration( + AutoConfigurations.of(MultitenancyCoreAutoConfiguration.class, MultitenancyWebAutoConfiguration.class)); + + @Test + void whenNoServletContextThenBackOff() { + var nonServletContextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(MultitenancyCoreAutoConfiguration.class, MultitenancyWebAutoConfiguration.class)); + + nonServletContextRunner + .run(context -> assertThatThrownBy(() -> context.getBean(HttpTenantResolutionConfiguration.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class)); + } + + @Test + void httpTenantResolutionDefault() { + contextRunner.run(context -> { + var bean = context.getBean(HttpTenantResolutionConfiguration.class); + assertThat(bean).isInstanceOf(HttpTenantResolutionConfiguration.class); + }); + } + + @Test + void httpTenantResolutionDisabled() { + contextRunner.withPropertyValues("arconia.multitenancy.resolution.http.enabled=false") + .run(context -> assertThatThrownBy(() -> context.getBean(HttpTenantResolutionConfiguration.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class)); + } + + @Test + void httpRequestTenantResolverDefault() { + contextRunner.run(context -> { + var bean = context.getBean(HttpRequestTenantResolver.class); + assertThat(bean).isInstanceOf(HeaderTenantResolver.class); + }); + } + + @Test + void httpRequestTenantResolverCookie() { + contextRunner.withPropertyValues("arconia.multitenancy.resolution.http.resolution-mode=cookie").run(context -> { + var bean = context.getBean(HttpRequestTenantResolver.class); + assertThat(bean).isInstanceOf(CookieTenantResolver.class); + }); + } + + @Test + void httpRequestTenantResolverFixed() { + contextRunner + .withPropertyValues("arconia.multitenancy.resolution.fixed.enabled=true", + "arconia.multitenancy.resolution.fixed.tenant-id=myTenant") + .run(context -> { + var bean = context.getBean(HttpRequestTenantResolver.class); + assertThat(bean).isInstanceOf(HttpRequestTenantResolver.class); + assertThat(bean.resolveTenantId(new MockHttpServletRequest())).isEqualTo("myTenant"); + }); + } + + @Test + void tenantContextFilterDefault() { + contextRunner.run(context -> { + var bean = context.getBean(TenantContextFilter.class); + assertThat(bean).isInstanceOf(TenantContextFilter.class); + }); + } + + @Test + void tenantContextIgnorePathMatcher() { + contextRunner + .withPropertyValues("arconia.multitenancy.resolution.http.filter.ignore-paths=/actuator/**,/status") + .run(context -> { + var bean = context.getBean(TenantContextIgnorePathMatcher.class); + assertThat(bean).isInstanceOf(TenantContextIgnorePathMatcher.class); + var mockRequest = new MockHttpServletRequest(); + mockRequest.setRequestURI("/actuator/prometheus"); + assertThat(bean.matches(mockRequest)).isTrue(); + }); + } + + @Test + void tenantContextFilterDisabled() { + contextRunner.withPropertyValues("arconia.multitenancy.resolution.http.filter.enabled=false") + .run(context -> assertThatThrownBy(() -> context.getBean(TenantContextFilter.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class)); + } + + @Test + void tenantContextIgnorePathMatcherDisabled() { + contextRunner.withPropertyValues("arconia.multitenancy.resolution.http.filter.enabled=false") + .run(context -> assertThatThrownBy(() -> context.getBean(TenantContextIgnorePathMatcher.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class)); + } + +} diff --git a/arconia-spring-boot-starters/arconia-web-spring-boot-starter/build.gradle b/arconia-spring-boot-starters/arconia-web-spring-boot-starter/build.gradle new file mode 100644 index 0000000..4845e23 --- /dev/null +++ b/arconia-spring-boot-starters/arconia-web-spring-boot-starter/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'code-quality-conventions' + id 'java-conventions' + id 'sbom-conventions' + id 'release-conventions' +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + + api project(':arconia-spring-boot-autoconfigure') + + api project(':arconia-core') + api project(':arconia-web') +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = "Arconia Web Spring Boot Starter" + description = "Arconia Web Spring Boot Starter." + } + } + } +} diff --git a/arconia-web/.gitignore b/arconia-web/.gitignore deleted file mode 100644 index c2065bc..0000000 --- a/arconia-web/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ diff --git a/arconia-web/build.gradle b/arconia-web/build.gradle index 8806856..96e6374 100644 --- a/arconia-web/build.gradle +++ b/arconia-web/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation 'jakarta.servlet:jakarta.servlet-api' implementation 'org.slf4j:slf4j-api' + implementation 'org.springframework:spring-context' implementation 'org.springframework:spring-web' diff --git a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextFilter.java b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextFilter.java index 8fa8312..b94213e 100644 --- a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextFilter.java +++ b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextFilter.java @@ -15,7 +15,7 @@ import io.arconia.core.multitenancy.context.events.TenantContextAttachedEvent; import io.arconia.core.multitenancy.context.events.TenantContextClosedEvent; import io.arconia.core.multitenancy.events.TenantEventPublisher; -import io.arconia.core.multitenancy.exceptions.TenantRequiredException; +import io.arconia.core.multitenancy.exceptions.TenantResolutionException; import io.arconia.web.multitenancy.context.resolvers.HttpRequestTenantResolver; /** @@ -27,17 +27,17 @@ public final class TenantContextFilter extends OncePerRequestFilter { private final HttpRequestTenantResolver httpRequestTenantResolver; - private final TenantOptionalPathMatcher tenantOptionalPathMatcher; + private final TenantContextIgnorePathMatcher tenantContextIgnorePathMatcher; private final TenantEventPublisher tenantEventPublisher; public TenantContextFilter(HttpRequestTenantResolver httpRequestTenantResolver, - TenantOptionalPathMatcher tenantOptionalPathMatcher, TenantEventPublisher tenantEventPublisher) { + TenantContextIgnorePathMatcher tenantContextIgnorePathMatcher, TenantEventPublisher tenantEventPublisher) { Assert.notNull(httpRequestTenantResolver, "httpRequestTenantResolver cannot be null"); - Assert.notNull(tenantOptionalPathMatcher, "ignorePathMatcher cannot be null"); + Assert.notNull(tenantContextIgnorePathMatcher, "ignorePathMatcher cannot be null"); Assert.notNull(tenantEventPublisher, "tenantEventPublisher cannot be null"); this.httpRequestTenantResolver = httpRequestTenantResolver; - this.tenantOptionalPathMatcher = tenantOptionalPathMatcher; + this.tenantContextIgnorePathMatcher = tenantContextIgnorePathMatcher; this.tenantEventPublisher = tenantEventPublisher; } @@ -45,24 +45,25 @@ public TenantContextFilter(HttpRequestTenantResolver httpRequestTenantResolver, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { var tenantId = httpRequestTenantResolver.resolveTenantId(request); - if (StringUtils.hasText(tenantId)) { - publishTenantContextAttachedEvent(tenantId, request); - } - else if (!tenantOptionalPathMatcher.matches(request)) { - throw new TenantRequiredException( + if (!StringUtils.hasText(tenantId)) { + throw new TenantResolutionException( "A tenant identifier must be specified for HTTP requests to: " + request.getRequestURI()); } + publishTenantContextAttachedEvent(tenantId, request); try { filterChain.doFilter(request, response); } finally { - if (StringUtils.hasText(tenantId)) { - publishTenantContextClosedEvent(tenantId, request); - } + publishTenantContextClosedEvent(tenantId, request); } } + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return tenantContextIgnorePathMatcher.matches(request); + } + private void publishTenantContextAttachedEvent(String tenantId, HttpServletRequest request) { var tenantContextAttachedEvent = new TenantContextAttachedEvent(tenantId, request); var observationContext = ServerHttpObservationFilter.findObservationContext(request); diff --git a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantOptionalPathMatcher.java b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextIgnorePathMatcher.java similarity index 59% rename from arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantOptionalPathMatcher.java rename to arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextIgnorePathMatcher.java index d75ea88..73ada94 100644 --- a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantOptionalPathMatcher.java +++ b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/filters/TenantContextIgnorePathMatcher.java @@ -12,31 +12,32 @@ import org.springframework.web.util.pattern.PathPatternParser; /** - * Matches HTTP requests paths for which a tenant context is optional. + * Matches HTTP requests paths for which a tenant context is not attached. * * @author Thomas Vitale */ -public class TenantOptionalPathMatcher { +public class TenantContextIgnorePathMatcher { - private static final Logger log = LoggerFactory.getLogger(TenantOptionalPathMatcher.class); + private static final Logger log = LoggerFactory.getLogger(TenantContextIgnorePathMatcher.class); - private final List optionalPathPatterns; + private final List ignorePathPatterns; - public TenantOptionalPathMatcher(List optionalPathPatterns) { - Assert.notNull(optionalPathPatterns, "optionalPathPatterns cannot be null"); - this.optionalPathPatterns = optionalPathPatterns.stream().map(this::parse).toList(); + public TenantContextIgnorePathMatcher(List ignorePathPatterns) { + Assert.notNull(ignorePathPatterns, "ignorePathPatterns cannot be null"); + this.ignorePathPatterns = ignorePathPatterns.stream().map(this::parse).toList(); } public boolean matches(HttpServletRequest httpServletRequest) { Assert.notNull(httpServletRequest, "httpServletRequest cannot be null"); var requestUri = httpServletRequest.getRequestURI(); var pathContainer = PathContainer.parsePath(requestUri); - var matchesOptionalPaths = optionalPathPatterns.stream() + var matchesIgnorePaths = ignorePathPatterns.stream() .anyMatch(pathPattern -> pathPattern.matches(pathContainer)); - if (matchesOptionalPaths) { - log.debug("Request '" + requestUri + "' matches one of the paths for which a tenant is optional"); + if (matchesIgnorePaths) { + log.debug( + "Request '" + requestUri + "' matches one of the paths for which a tenant context is not attached"); } - return matchesOptionalPaths; + return matchesIgnorePaths; } private PathPattern parse(String pattern) { diff --git a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolver.java b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolver.java index f3ed536..c1e614b 100644 --- a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolver.java +++ b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolver.java @@ -39,11 +39,4 @@ public String resolveTenantId(HttpServletRequest request) { .orElse(null); } - /** - * The name of the Cookie containing the tenant identifier. - */ - public String getTenantCookieName() { - return tenantCookieName; - } - } diff --git a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolver.java b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolver.java index ebce699..83c9d49 100644 --- a/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolver.java +++ b/arconia-web/src/main/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolver.java @@ -31,11 +31,4 @@ public String resolveTenantId(HttpServletRequest request) { return request.getHeader(tenantHeaderName); } - /** - * The name of the HTTP Header containing the tenant identifier. - */ - public String getTenantHeaderName() { - return tenantHeaderName; - } - } diff --git a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextFilterTests.java b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextFilterTests.java index 4e7cbbb..6eb576a 100644 --- a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextFilterTests.java +++ b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextFilterTests.java @@ -18,18 +18,23 @@ import io.arconia.core.multitenancy.context.events.TenantContextClosedEvent; import io.arconia.core.multitenancy.events.TenantEvent; import io.arconia.core.multitenancy.events.TenantEventPublisher; -import io.arconia.core.multitenancy.exceptions.TenantRequiredException; +import io.arconia.core.multitenancy.exceptions.TenantResolutionException; import io.arconia.web.multitenancy.context.resolvers.HeaderTenantResolver; import io.arconia.web.multitenancy.context.resolvers.HttpRequestTenantResolver; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link TenantContextFilter}. + * + * @author Thomas Vitale + */ class TenantContextFilterTests { @Test void whenNullTenantResolverThenThrow() { - var noTenantPathMatcher = Mockito.mock(TenantOptionalPathMatcher.class); + var noTenantPathMatcher = Mockito.mock(TenantContextIgnorePathMatcher.class); var tenantEventPublisher = Mockito.mock(TenantEventPublisher.class); assertThatThrownBy(() -> new TenantContextFilter(null, noTenantPathMatcher, tenantEventPublisher)) .isInstanceOf(IllegalArgumentException.class) @@ -48,7 +53,7 @@ void whenNullPathMatcherThenThrow() { @Test void whenNullEventPublisherThenThrow() { var httpRequestTenantResolver = Mockito.mock(HttpRequestTenantResolver.class); - var noTenantPathMatcher = Mockito.mock(TenantOptionalPathMatcher.class); + var noTenantPathMatcher = Mockito.mock(TenantContextIgnorePathMatcher.class); assertThatThrownBy(() -> new TenantContextFilter(httpRequestTenantResolver, noTenantPathMatcher, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("tenantEventPublisher cannot be null"); @@ -64,7 +69,7 @@ void whenTenantResolvedThenPublishEvent() throws ServletException, IOException { var response = new MockHttpServletResponse(); var filterChain = new MockFilterChain(); var httpRequestTenantResolver = new HeaderTenantResolver(); - var noTenantPathMatcher = new TenantOptionalPathMatcher(List.of()); + var noTenantPathMatcher = new TenantContextIgnorePathMatcher(List.of()); var tenantEventPublisher = Mockito.mock(TenantEventPublisher.class); var filter = new TenantContextFilter(httpRequestTenantResolver, noTenantPathMatcher, tenantEventPublisher); @@ -88,31 +93,31 @@ void whenTenantResolvedThenPublishEvent() throws ServletException, IOException { } @Test - void whenRequiredTenantNotResolvedThenThrow() throws ServletException, IOException { + void whenRequiredTenantNotResolvedThenThrow() { var request = new MockHttpServletRequest(); var response = new MockHttpServletResponse(); var filterChain = new MockFilterChain(); var httpRequestTenantResolver = new HeaderTenantResolver(); - var noTenantPathMatcher = new TenantOptionalPathMatcher(List.of()); + var noTenantPathMatcher = new TenantContextIgnorePathMatcher(List.of()); var tenantEventPublisher = Mockito.mock(TenantEventPublisher.class); var filter = new TenantContextFilter(httpRequestTenantResolver, noTenantPathMatcher, tenantEventPublisher); assertThatThrownBy(() -> filter.doFilter(request, response, filterChain)) - .isInstanceOf(TenantRequiredException.class) + .isInstanceOf(TenantResolutionException.class) .hasMessageContaining("A tenant identifier must be specified for HTTP requests"); Mockito.verify(tenantEventPublisher, Mockito.times(0)).publishTenantEvent(Mockito.any(TenantEvent.class)); } @Test - void whenOptionalTenantNotResolvedThenNoEventPublished() throws ServletException, IOException { - var path = "/optional-path"; + void whenIgnorePathThenNoTenantResolvedAndNoEventPublished() throws ServletException, IOException { + var path = "/ignore-path"; var request = new MockHttpServletRequest(); request.setRequestURI(path); var response = new MockHttpServletResponse(); var filterChain = new MockFilterChain(); var httpRequestTenantResolver = new HeaderTenantResolver(); - var noTenantPathMatcher = new TenantOptionalPathMatcher(List.of(path)); + var noTenantPathMatcher = new TenantContextIgnorePathMatcher(List.of(path)); var tenantEventPublisher = Mockito.mock(TenantEventPublisher.class); var filter = new TenantContextFilter(httpRequestTenantResolver, noTenantPathMatcher, tenantEventPublisher); diff --git a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantOptionalPathMatcherTests.java b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextIgnorePathMatcherTests.java similarity index 65% rename from arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantOptionalPathMatcherTests.java rename to arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextIgnorePathMatcherTests.java index d8ff042..391477d 100644 --- a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantOptionalPathMatcherTests.java +++ b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/filters/TenantContextIgnorePathMatcherTests.java @@ -8,19 +8,24 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -class TenantOptionalPathMatcherTests { +/** + * Unit tests for {@link TenantContextIgnorePathMatcher}. + * + * @author Thomas Vitale + */ +class TenantContextIgnorePathMatcherTests { @Test void whenNullPathsThenThrow() { - assertThatThrownBy(() -> new TenantOptionalPathMatcher(null)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("optionalPathPatterns cannot be null"); + assertThatThrownBy(() -> new TenantContextIgnorePathMatcher(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ignorePathPatterns cannot be null"); } @Test void matchAgainstFullPath() { var request = new MockHttpServletRequest(); request.setRequestURI("/actuator/prometheus"); - var matcher = new TenantOptionalPathMatcher(List.of("/actuator/prometheus")); + var matcher = new TenantContextIgnorePathMatcher(List.of("/actuator/prometheus")); assertThat(matcher.matches(request)).isTrue(); } @@ -28,7 +33,7 @@ void matchAgainstFullPath() { void matchAgainstFullPathWithoutTrailingSlash() { var request = new MockHttpServletRequest(); request.setRequestURI("/actuator/prometheus"); - var matcher = new TenantOptionalPathMatcher(List.of("actuator/prometheus")); + var matcher = new TenantContextIgnorePathMatcher(List.of("actuator/prometheus")); assertThat(matcher.matches(request)).isTrue(); } @@ -36,7 +41,7 @@ void matchAgainstFullPathWithoutTrailingSlash() { void matchAgainstTemplatePath() { var request = new MockHttpServletRequest(); request.setRequestURI("/actuator/prometheus"); - var matcher = new TenantOptionalPathMatcher(List.of("/actuator/**")); + var matcher = new TenantContextIgnorePathMatcher(List.of("/actuator/**")); assertThat(matcher.matches(request)).isTrue(); } @@ -44,13 +49,13 @@ void matchAgainstTemplatePath() { void matchDifferentPathsThenFalse() { var request = new MockHttpServletRequest(); request.setRequestURI("/actuators"); - var matcher = new TenantOptionalPathMatcher(List.of("/actuator/**")); + var matcher = new TenantContextIgnorePathMatcher(List.of("/actuator/**")); assertThat(matcher.matches(request)).isFalse(); } @Test void whenNullRequestThenThrow() { - var matcher = new TenantOptionalPathMatcher(List.of("/actuator/**")); + var matcher = new TenantContextIgnorePathMatcher(List.of("/actuator/**")); assertThatThrownBy(() -> matcher.matches(null)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("httpServletRequest cannot be null"); } diff --git a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolverTests.java b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolverTests.java index 334c87d..d5b3980 100644 --- a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolverTests.java +++ b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/CookieTenantResolverTests.java @@ -8,6 +8,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link CookieTenantResolver}. + * + * @author Thomas Vitale + */ class CookieTenantResolverTests { @Test diff --git a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolverTests.java b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolverTests.java index 816baa0..823b2bf 100644 --- a/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolverTests.java +++ b/arconia-web/src/test/java/io/arconia/web/multitenancy/context/resolvers/HeaderTenantResolverTests.java @@ -6,6 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link HeaderTenantResolver}. + * + * @author Thomas Vitale + */ class HeaderTenantResolverTests { @Test diff --git a/settings.gradle b/settings.gradle index c9e2c1b..7add0e5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,3 +6,6 @@ rootProject.name = 'arconia' include 'arconia-core' include 'arconia-web' + +include 'arconia-spring-boot-autoconfigure' +include 'arconia-spring-boot-starters:arconia-web-spring-boot-starter'