├── .gitignore
├── LICENSE
├── README.md
├── docker-compose.yml
└── multi-tenant-service
├── README.md
├── pom.xml
└── src
├── main
├── java
│ └── se
│ │ └── callista
│ │ └── blog
│ │ └── service
│ │ ├── MultiTenantServiceApplication.java
│ │ ├── config
│ │ ├── DataSourceConfiguration.java
│ │ ├── LiquibaseConfig.java
│ │ └── WebConfiguration.java
│ │ ├── controller
│ │ ├── AbstractBaseApiController.java
│ │ ├── ApiException.java
│ │ ├── ContentType.java
│ │ ├── NotFoundException.java
│ │ └── ProductApiController.java
│ │ ├── domain
│ │ └── entity
│ │ │ └── Product.java
│ │ ├── model
│ │ ├── ErrorMessage.java
│ │ └── ProductValue.java
│ │ ├── multi_tenancy
│ │ ├── async
│ │ │ ├── AsyncConfig.java
│ │ │ └── TenantAwareTaskDecorator.java
│ │ ├── interceptor
│ │ │ └── TenantInterceptor.java
│ │ └── util
│ │ │ └── TenantContext.java
│ │ ├── repository
│ │ └── ProductRepository.java
│ │ └── services
│ │ ├── ProductService.java
│ │ └── ProductServiceImpl.java
└── resources
│ ├── application.yml
│ └── db
│ └── changelog
│ ├── db.changelog-tenant-1.0.yaml
│ └── db.changelog-tenant.yaml
└── test
├── java
└── se
│ └── callista
│ └── blog
│ └── service
│ ├── MultiTenantServiceApplicationTests.java
│ ├── annotation
│ ├── SpringBootDbIntegrationTest.java
│ └── SpringBootIntegrationTest.java
│ ├── controller
│ └── ProductApiControllerTest.java
│ ├── persistence
│ └── PostgresqlTestContainer.java
│ ├── repository
│ └── ProductRepositoryTest.java
│ └── services
│ └── ProductServiceTest.java
└── resources
├── application-default.yml
├── application-integration-test.yml
├── application-test.yml
├── datasets
└── products.yml
└── dbunit.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | # Maven #
2 | target/
3 |
4 | ### Eclipse ###
5 | .apt_generated
6 | .classpath
7 | .factorypath
8 | .project
9 | .settings
10 | .springBeans
11 | .sts4-cache
12 |
13 | ### IntelliJ IDEA ###
14 | .idea/
15 | *.iws
16 | *.iml
17 | *.ipr
18 |
19 | ### VS Code ###
20 | .vscode/
21 |
22 | ### Other ###
23 | *.orig
24 | *.hprof
25 | lombok.config
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Multi Tenancy with Spring Boot, Hibernate & Liquibase
2 |
3 | ## Overview
4 |
5 | Multi Tenancy usually plays an important role in the business case for
6 | SAAS solutions. Spring Data and Hibernate provide out-of-the-box support
7 | for different Multi-tenancy strategies. Configuration however becomes more
8 | complicated, and the available examples are few.
9 |
10 | This project complements my blog series on Multi Tenancy
11 | (see https://callistaenterprise.se/blogg/teknik/2020/09/19/multi-tenancy-with-spring-boot-part1/),
12 | and contains working examples of different Multi Tenant strategies implemented with
13 | Spring Boot, Hibemate and Liquibase, complete with support for database
14 | migrations as well as dynamically set up new tenants on the fly.
15 |
16 | ## How to use the examples
17 |
18 | The master branch contains a common, minimal example project skeleton. The
19 | different Multi-tenancy strategy examples are in separate branches.
20 |
21 | ### Database per tenant
22 |
23 | The `database` branch implements the *Database per tenant* strategy.
24 |
25 | ### Schema per tenant
26 |
27 | The `schema` branch implements the *Schema per tenant* strategy.
28 |
29 | ### Shared Database with Discriminator, using Hibernate Filters
30 |
31 | The `shared_database_hibernate` branch implements the *Shared Database with Discriminator*
32 | strategy, using Hibernate's experimental support for discriminator-based multi-tenancy
33 | (see e.g. https://hibernate.atlassian.net/browse/HHH-6054)
34 |
35 | ### Shared Database with Discriminator, using PostgreSQL's Row Level Security
36 |
37 | The `shared_database_postgres_rls` branch implements the *Shared Database with Discriminator*
38 | strategy, using PostgreSQL's Row Level Security.
39 |
40 | ## How to start a Dockerized postgres database
41 |
42 | All the examples require a postgres database running at localhost:5432. Run the following command
43 | to use the provided `docker-compose.yml` configuration to start a dockerized postgres
44 | container:
45 |
46 | ```
47 | docker-compose up -d
48 | ```
49 |
50 | Close it down with the following command when done, or if you need to recreate the database:
51 |
52 | ```
53 | docker-compose down
54 | ```
55 |
56 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2.1'
2 | services:
3 | postgres:
4 | image: postgres
5 | ports:
6 | - "5432:5432"
7 | restart: always
8 | environment:
9 | POSTGRES_USER: postgres
10 | POSTGRES_PASSWORD: secret
11 | POSTGRES_DB: blog
12 | healthcheck:
13 | test: ["CMD-SHELL", "pg_isready -U postgres -d blog"]
14 | interval: 10s
15 | timeout: 5s
16 | retries: 3
17 |
--------------------------------------------------------------------------------
/multi-tenant-service/README.md:
--------------------------------------------------------------------------------
1 | # Multi Tenant Service
2 |
3 | ## Running the Multi Tenant Service
4 |
5 | Build the Multi Tenant Service executable:
6 |
7 | ```
8 | mvn package
9 | ```
10 |
11 | then start it as an simple java application:
12 |
13 | ```
14 | java -jar target/multi-tenant-service-0-SNAPSHOT.jar
15 | ```
16 | or via maven
17 | ```
18 | mvn spring-boot:run
19 | ```
20 |
21 | ## Testing the Multi Tenant Service
22 |
23 | Insert some test data for different tenants:
24 |
25 | ```
26 | curl -H "X-TENANT-ID: tenant1" -H "Content-Type: application/se.callista.blog.service.api.product.v1_0+json" -X POST -d '{"name":"Product 1"}' localhost:8080/products
27 | curl -H "X-TENANT-ID: tenant2" -H "Content-Type: application/se.callista.blog.service.api.product.v1_0+json" -X POST -d '{"name":"Product 2"}' localhost:8080/products
28 | ```
29 |
30 | Then query for the data, and verify that the data is properly isolated between tenants:
31 |
32 | ```
33 | curl -H "X-TENANT-ID: tenant1" localhost:8080/products
34 | curl -H "X-TENANT-ID: tenant2" localhost:8080/products
35 | ```
36 |
37 | ## Configuration
38 |
39 | Change default port value and other settings in src/main/resources/application.yml.
40 |
--------------------------------------------------------------------------------
/multi-tenant-service/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | org.springframework.boot
7 | spring-boot-starter-parent
8 | 2.3.3.RELEASE
9 |
10 |
11 | se.callista.blog
12 | multi-tenant-service
13 | 0-SNAPSHOT
14 | multi-tenant-service
15 | Sample Multi-tenant Service
16 |
17 |
18 | 1.8
19 |
20 |
21 |
22 |
23 | org.springframework.boot
24 | spring-boot-starter-data-jpa
25 |
26 |
27 | javax.validation
28 | validation-api
29 | 2.0.1.Final
30 |
31 |
32 | org.springframework.boot
33 | spring-boot-starter-web
34 |
35 |
36 | com.google.guava
37 | guava
38 | 29.0-jre
39 |
40 |
41 | org.liquibase
42 | liquibase-core
43 |
44 |
45 | org.projectlombok
46 | lombok
47 | provided
48 |
49 |
50 |
51 | org.springframework.boot
52 | spring-boot-devtools
53 | runtime
54 | true
55 |
56 |
57 | org.postgresql
58 | postgresql
59 | runtime
60 |
61 |
62 | org.springframework.boot
63 | spring-boot-starter-test
64 | test
65 |
66 |
67 | org.junit.vintage
68 | junit-vintage-engine
69 |
70 |
71 |
72 |
73 | org.junit.jupiter
74 | junit-jupiter
75 | test
76 |
77 |
78 | org.testcontainers
79 | junit-jupiter
80 | 1.14.3
81 | test
82 |
83 |
84 | org.testcontainers
85 | postgresql
86 | 1.14.3
87 | test
88 |
89 |
90 | com.github.database-rider
91 | rider-spring
92 | 1.16.0
93 | test
94 |
95 |
96 | org.slf4j
97 | slf4j-simple
98 |
99 |
100 |
101 |
102 | com.github.database-rider
103 | rider-junit5
104 | 1.16.0
105 | test
106 |
107 |
108 |
109 |
110 |
111 |
112 | org.springframework.boot
113 | spring-boot-maven-plugin
114 |
115 |
116 | org.apache.maven.plugins
117 | maven-surefire-plugin
118 | 3.0.0-M5
119 |
120 |
121 | **/*.class
122 |
123 | integration
124 |
125 |
126 |
127 | org.apache.maven.plugins
128 | maven-failsafe-plugin
129 | 3.0.0-M5
130 |
131 |
132 | **/*.class
133 |
134 | integration
135 |
136 |
137 |
138 |
139 |
140 |
141 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/MultiTenantServiceApplication.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service;
2 |
3 | import org.springframework.boot.SpringApplication;
4 | import org.springframework.boot.autoconfigure.SpringBootApplication;
5 | import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
6 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration;
7 | import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
8 | import org.springframework.scheduling.annotation.EnableAsync;
9 | import org.springframework.transaction.annotation.EnableTransactionManagement;
10 |
11 | @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, LiquibaseAutoConfiguration.class })
12 | @EnableTransactionManagement
13 | @EnableAsync
14 | public class MultiTenantServiceApplication extends SpringBootServletInitializer {
15 |
16 | public static void main(String[] args) {
17 | SpringApplication.run(MultiTenantServiceApplication.class, args);
18 | }
19 |
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/config/DataSourceConfiguration.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.config;
2 |
3 | import com.zaxxer.hikari.HikariDataSource;
4 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
5 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseDataSource;
6 | import org.springframework.boot.context.properties.ConfigurationProperties;
7 | import org.springframework.context.annotation.Bean;
8 | import org.springframework.context.annotation.Configuration;
9 | import org.springframework.stereotype.Component;
10 |
11 | import javax.sql.DataSource;
12 |
13 | @Component
14 | @Configuration
15 | public class DataSourceConfiguration {
16 |
17 | @Bean
18 | @ConfigurationProperties("multitenancy.master.datasource")
19 | public DataSourceProperties masterDataSourceProperties() {
20 | return new DataSourceProperties();
21 | }
22 |
23 | @Bean
24 | @LiquibaseDataSource
25 | @ConfigurationProperties("multitenancy.master.datasource.hikari")
26 | public DataSource masterDataSource() {
27 | HikariDataSource dataSource = masterDataSourceProperties()
28 | .initializeDataSourceBuilder()
29 | .type(HikariDataSource.class)
30 | .build();
31 | dataSource.setPoolName("masterDataSource");
32 | return dataSource;
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/config/LiquibaseConfig.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.config;
2 |
3 | import liquibase.integration.spring.SpringLiquibase;
4 | import org.springframework.beans.factory.ObjectProvider;
5 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseDataSource;
6 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties;
7 | import org.springframework.boot.context.properties.ConfigurationProperties;
8 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
9 | import org.springframework.context.annotation.Bean;
10 | import org.springframework.context.annotation.Configuration;
11 | import org.springframework.context.annotation.Lazy;
12 |
13 | import javax.sql.DataSource;
14 |
15 | @Lazy(false)
16 | @Configuration
17 | @EnableConfigurationProperties(LiquibaseProperties.class)
18 | public class LiquibaseConfig {
19 |
20 | @Bean
21 | @ConfigurationProperties("multitenancy.master.liquibase")
22 | public LiquibaseProperties masterLiquibaseProperties() {
23 | return new LiquibaseProperties();
24 | }
25 |
26 | @Bean
27 | public SpringLiquibase liquibase(@LiquibaseDataSource ObjectProvider liquibaseDataSource) {
28 | LiquibaseProperties liquibaseProperties = masterLiquibaseProperties();
29 | SpringLiquibase liquibase = new SpringLiquibase();
30 | liquibase.setDataSource(liquibaseDataSource.getIfAvailable());
31 | liquibase.setChangeLog(liquibaseProperties.getChangeLog());
32 | liquibase.setContexts(liquibaseProperties.getContexts());
33 | liquibase.setDefaultSchema(liquibaseProperties.getDefaultSchema());
34 | liquibase.setLiquibaseSchema(liquibaseProperties.getLiquibaseSchema());
35 | liquibase.setLiquibaseTablespace(liquibaseProperties.getLiquibaseTablespace());
36 | liquibase.setDatabaseChangeLogTable(liquibaseProperties.getDatabaseChangeLogTable());
37 | liquibase.setDatabaseChangeLogLockTable(liquibaseProperties.getDatabaseChangeLogLockTable());
38 | liquibase.setDropFirst(liquibaseProperties.isDropFirst());
39 | liquibase.setShouldRun(liquibaseProperties.isEnabled());
40 | liquibase.setLabels(liquibaseProperties.getLabels());
41 | liquibase.setChangeLogParameters(liquibaseProperties.getParameters());
42 | liquibase.setRollbackFile(liquibaseProperties.getRollbackFile());
43 | liquibase.setTestRollbackOnUpdate(liquibaseProperties.isTestRollbackOnUpdate());
44 | return liquibase;
45 | }
46 |
47 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/config/WebConfiguration.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.config;
2 |
3 | import org.springframework.beans.factory.annotation.Autowired;
4 | import org.springframework.context.annotation.Configuration;
5 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
7 | import se.callista.blog.service.multi_tenancy.interceptor.TenantInterceptor;
8 |
9 | @Configuration
10 | public class WebConfiguration implements WebMvcConfigurer {
11 |
12 | private final TenantInterceptor tenantInterceptor;
13 |
14 | @Autowired
15 | public WebConfiguration(TenantInterceptor tenantInterceptor) {
16 | this.tenantInterceptor = tenantInterceptor;
17 | }
18 |
19 | @Override
20 | public void addInterceptors(InterceptorRegistry registry) {
21 | registry.addWebRequestInterceptor(tenantInterceptor);
22 | }
23 |
24 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/controller/AbstractBaseApiController.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.controller;
2 |
3 | import org.springframework.http.HttpStatus;
4 | import org.springframework.http.ResponseEntity;
5 | import org.springframework.web.bind.annotation.ExceptionHandler;
6 | import org.springframework.web.bind.annotation.RequestMapping;
7 | import org.springframework.web.bind.annotation.RestController;
8 | import org.springframework.web.context.request.ServletWebRequest;
9 | import org.springframework.web.context.request.WebRequest;
10 | import se.callista.blog.service.model.ErrorMessage;
11 |
12 | import javax.servlet.http.HttpServletRequest;
13 |
14 | @RestController
15 | @RequestMapping("/")
16 | public class AbstractBaseApiController {
17 |
18 | @ExceptionHandler(ApiException.class)
19 | public final ResponseEntity handleApiException(ApiException ex, WebRequest request) {
20 | HttpStatus status = ex.getStatus();
21 | ErrorMessage errorDetails =
22 | ErrorMessage.builder()
23 | .timestamp(ex.getTimestamp())
24 | .status(status.value())
25 | .error(status.getReasonPhrase())
26 | .message(ex.getMessage())
27 | .build();
28 | if (request instanceof ServletWebRequest) {
29 | ServletWebRequest servletWebRequest = (ServletWebRequest) request;
30 | HttpServletRequest servletRequest = servletWebRequest.getNativeRequest(HttpServletRequest.class);
31 | errorDetails.setPath(servletRequest.getRequestURI());
32 | }
33 | return new ResponseEntity<>(errorDetails, status);
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/controller/ApiException.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.controller;
2 |
3 | import org.springframework.http.HttpStatus;
4 |
5 | import java.time.OffsetDateTime;
6 | import java.time.ZoneId;
7 |
8 | public class ApiException extends RuntimeException {
9 |
10 | private static final long serialVersionUID = 1L;
11 | private static final ZoneId utc = ZoneId.of("UTC");
12 | private final OffsetDateTime timestamp;
13 | private final HttpStatus status;
14 |
15 | public ApiException(HttpStatus status, String msg) {
16 | super(msg);
17 | this.timestamp = OffsetDateTime.now(utc);
18 | this.status = status;
19 | }
20 |
21 | public OffsetDateTime getTimestamp() {
22 | return timestamp;
23 | }
24 |
25 | public HttpStatus getStatus() {
26 | return status;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/controller/ContentType.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.controller;
2 |
3 | public final class ContentType {
4 |
5 | private ContentType() {}
6 |
7 | public static final String PRODUCT_1_0 = "application/se.callista.blog.service.api.product.v1_0+json";
8 | public static final String PRODUCTS_1_0 = "application/se.callista.blog.service.api.products.v1_0+json";
9 |
10 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/controller/NotFoundException.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.controller;
2 |
3 | import org.springframework.http.HttpStatus;
4 |
5 | public class NotFoundException extends ApiException {
6 |
7 | private static final long serialVersionUID = 1L;
8 |
9 | public NotFoundException(String msg) {
10 | super(HttpStatus.NOT_FOUND, msg);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/controller/ProductApiController.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.controller;
2 |
3 | import org.springframework.beans.factory.annotation.Autowired;
4 | import org.springframework.http.HttpHeaders;
5 | import org.springframework.http.HttpStatus;
6 | import org.springframework.http.ResponseEntity;
7 | import org.springframework.util.LinkedMultiValueMap;
8 | import org.springframework.util.MultiValueMap;
9 | import org.springframework.web.bind.annotation.DeleteMapping;
10 | import org.springframework.web.bind.annotation.GetMapping;
11 | import org.springframework.web.bind.annotation.PathVariable;
12 | import org.springframework.web.bind.annotation.PostMapping;
13 | import org.springframework.web.bind.annotation.PutMapping;
14 | import org.springframework.web.bind.annotation.RequestBody;
15 | import org.springframework.web.bind.annotation.RequestMapping;
16 | import org.springframework.web.bind.annotation.RestController;
17 | import se.callista.blog.service.model.ProductValue;
18 | import se.callista.blog.service.services.ProductService;
19 |
20 | import javax.persistence.EntityNotFoundException;
21 | import javax.validation.Valid;
22 | import java.util.List;
23 | import java.util.concurrent.CompletableFuture;
24 |
25 | @RestController
26 | @RequestMapping("/")
27 | public class ProductApiController extends AbstractBaseApiController {
28 |
29 | private final ProductService productService;
30 |
31 | @Autowired
32 | public ProductApiController(ProductService productService) {
33 | this.productService = productService;
34 | }
35 |
36 | @GetMapping(value = "/products", produces = {ContentType.PRODUCTS_1_0})
37 | public ResponseEntity> getProducts() {
38 | List productValues = productService.getProducts();
39 | return new ResponseEntity<>(productValues, HttpStatus.OK);
40 | }
41 |
42 | @GetMapping(value = "/products/{productId}", produces = {ContentType.PRODUCT_1_0})
43 | public ResponseEntity getProduct(@PathVariable("productId") long productId) {
44 | try {
45 | ProductValue branch = productService.getProduct(productId);
46 | return new ResponseEntity<>(branch, HttpStatus.OK);
47 | } catch (EntityNotFoundException e) {
48 | throw new NotFoundException(e.getMessage());
49 | }
50 | }
51 |
52 | @PostMapping(value = "/products",
53 | consumes = {ContentType.PRODUCT_1_0},
54 | produces = {ContentType.PRODUCT_1_0})
55 | public ResponseEntity createProduct(@Valid @RequestBody ProductValue productValue) {
56 | ProductValue product = productService.createProduct(productValue);
57 | MultiValueMap headers = new LinkedMultiValueMap<>();
58 | headers.add(HttpHeaders.LOCATION, "/products/" + product.getProductId());
59 | return new ResponseEntity<>(product, headers, HttpStatus.CREATED);
60 | }
61 |
62 | @PutMapping(value = "/products/{productId}",
63 | consumes = {ContentType.PRODUCT_1_0},
64 | produces = {ContentType.PRODUCT_1_0})
65 | ResponseEntity updateProduct(@PathVariable long productId, @Valid @RequestBody ProductValue productValue) {
66 | productValue.setProductId(productId);
67 | try {
68 | ProductValue product = productService.updateProduct(productValue);
69 | return new ResponseEntity<>(product, HttpStatus.OK);
70 | } catch (EntityNotFoundException e) {
71 | throw new NotFoundException(e.getMessage());
72 | }
73 | }
74 |
75 | @DeleteMapping("/products/{productId}")
76 | ResponseEntity deleteProduct(@PathVariable long productId) {
77 | try {
78 | productService.deleteProductById(productId);
79 | return new ResponseEntity<>(HttpStatus.NO_CONTENT);
80 | } catch (EntityNotFoundException e) {
81 | throw new NotFoundException(e.getMessage());
82 | }
83 | }
84 |
85 | @GetMapping(value = "/async/products", produces = {ContentType.PRODUCTS_1_0})
86 | public CompletableFuture>> asyncGetProducts() {
87 | List productValues = productService.getProducts();
88 | return CompletableFuture.completedFuture(new ResponseEntity<>(productValues, HttpStatus.OK));
89 | }
90 |
91 | @GetMapping(value = "/async/products/{productId}", produces = {ContentType.PRODUCT_1_0})
92 | public CompletableFuture> asyncGetProduct(@PathVariable("productId") long productId) {
93 | return CompletableFuture.completedFuture(getProduct(productId));
94 | }
95 |
96 | }
97 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/domain/entity/Product.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.domain.entity;
2 |
3 | import lombok.Builder;
4 | import lombok.Getter;
5 | import lombok.NoArgsConstructor;
6 | import lombok.Setter;
7 |
8 | import javax.persistence.Column;
9 | import javax.persistence.Entity;
10 | import javax.persistence.GeneratedValue;
11 | import javax.persistence.GenerationType;
12 | import javax.persistence.Id;
13 | import javax.persistence.SequenceGenerator;
14 | import javax.persistence.Table;
15 | import javax.persistence.Version;
16 | import javax.validation.constraints.NotNull;
17 | import javax.validation.constraints.Size;
18 |
19 | @Entity
20 | @Table(name = "product")
21 | @Getter
22 | @Setter
23 | @NoArgsConstructor
24 | @Builder
25 | public class Product {
26 |
27 | @Builder
28 | public Product(Long id, String name, Integer version) {
29 | this.id = id;
30 | this.name = name;
31 | this.version = version;
32 | }
33 |
34 | @Id
35 | @Column(name = "id", unique = true, nullable = false, updatable = false)
36 | @SequenceGenerator(name="product_seq", sequenceName="product_seq", allocationSize=50)
37 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator="product_seq")
38 | protected Long id;
39 |
40 | @Column(name = "name", length = 255, nullable = false)
41 | @NotNull
42 | @Size(max = 255)
43 | private String name;
44 |
45 | @Version
46 | @Column(name = "version", nullable = false, columnDefinition = "int default 0")
47 | protected Integer version;
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/model/ErrorMessage.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.model;
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty;
4 | import lombok.AllArgsConstructor;
5 | import lombok.Builder;
6 | import lombok.Data;
7 | import lombok.NoArgsConstructor;
8 |
9 | import javax.validation.constraints.NotNull;
10 | import javax.validation.constraints.Size;
11 | import java.time.OffsetDateTime;
12 |
13 | /**
14 | * A message containing more info why an operation failed.
15 | */
16 | @Data
17 | @NoArgsConstructor
18 | @AllArgsConstructor
19 | @Builder
20 | public class ErrorMessage {
21 |
22 | @JsonProperty("timestamp")
23 | @NotNull
24 | private OffsetDateTime timestamp;
25 |
26 | @JsonProperty("status")
27 | @NotNull
28 | private Integer status;
29 |
30 | @JsonProperty("error")
31 | @NotNull
32 | private String error;
33 |
34 | @JsonProperty("message")
35 | @NotNull
36 | @Size(max=255)
37 | private String message;
38 |
39 | @JsonProperty("path")
40 | private String path;
41 |
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/model/ProductValue.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.model;
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty;
4 | import lombok.AllArgsConstructor;
5 | import lombok.Builder;
6 | import lombok.Data;
7 | import lombok.NoArgsConstructor;
8 | import se.callista.blog.service.domain.entity.Product;
9 |
10 | import javax.validation.constraints.NotNull;
11 | import javax.validation.constraints.Size;
12 |
13 | @Data
14 | @NoArgsConstructor
15 | @AllArgsConstructor
16 | @Builder
17 | public class ProductValue {
18 |
19 | @JsonProperty("productId")
20 | private Long productId;
21 |
22 | @NotNull
23 | @Size(max = 255)
24 | @JsonProperty("name")
25 | private String name;
26 |
27 | public static ProductValue fromEntity(Product product) {
28 | return ProductValue.builder()
29 | .productId(product.getId())
30 | .name(product.getName())
31 | .build();
32 | }
33 |
34 | public static Product fromValue(ProductValue product) {
35 | return Product.builder()
36 | .id(product.getProductId())
37 | .name(product.getName())
38 | .build();
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/async/AsyncConfig.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.multi_tenancy.async;
2 |
3 | import org.springframework.context.annotation.Configuration;
4 | import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
5 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
6 |
7 | import java.util.concurrent.Executor;
8 |
9 | @Configuration
10 | public class AsyncConfig extends AsyncConfigurerSupport {
11 |
12 | @Override
13 | public Executor getAsyncExecutor() {
14 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
15 |
16 | executor.setCorePoolSize(7);
17 | executor.setMaxPoolSize(42);
18 | executor.setQueueCapacity(11);
19 | executor.setThreadNamePrefix("TenantAwareTaskExecutor-");
20 | executor.setTaskDecorator(new TenantAwareTaskDecorator());
21 | executor.initialize();
22 |
23 | return executor;
24 | }
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/async/TenantAwareTaskDecorator.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.multi_tenancy.async;
2 |
3 | import org.springframework.core.task.TaskDecorator;
4 | import org.springframework.lang.NonNull;
5 | import se.callista.blog.service.multi_tenancy.util.TenantContext;
6 |
7 | public class TenantAwareTaskDecorator implements TaskDecorator {
8 |
9 | @Override
10 | @NonNull
11 | public Runnable decorate(@NonNull Runnable runnable) {
12 | String tenantId = TenantContext.getTenantId();
13 | return () -> {
14 | try {
15 | TenantContext.setTenantId(tenantId);
16 | runnable.run();
17 | } finally {
18 | TenantContext.setTenantId(null);
19 | }
20 | };
21 | }
22 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/interceptor/TenantInterceptor.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.multi_tenancy.interceptor;
2 |
3 | import org.springframework.beans.factory.annotation.Autowired;
4 | import org.springframework.beans.factory.annotation.Value;
5 | import org.springframework.lang.NonNull;
6 | import org.springframework.stereotype.Component;
7 | import org.springframework.ui.ModelMap;
8 | import org.springframework.web.context.request.ServletWebRequest;
9 | import org.springframework.web.context.request.WebRequest;
10 | import org.springframework.web.context.request.WebRequestInterceptor;
11 | import se.callista.blog.service.multi_tenancy.util.TenantContext;
12 |
13 | @Component
14 | public class TenantInterceptor implements WebRequestInterceptor {
15 |
16 | private final String defaultTenant;
17 |
18 | @Autowired
19 | public TenantInterceptor(
20 | @Value("${multitenancy.tenant.default-tenant:#{null}}") String defaultTenant) {
21 | this.defaultTenant = defaultTenant;
22 | }
23 |
24 | @Override
25 | public void preHandle(WebRequest request) {
26 | String tenantId;
27 | if (request.getHeader("X-TENANT-ID") != null) {
28 | tenantId = request.getHeader("X-TENANT-ID");
29 | } else if (this.defaultTenant != null) {
30 | tenantId = this.defaultTenant;
31 | } else {
32 | tenantId = ((ServletWebRequest)request).getRequest().getServerName().split("\\.")[0];
33 | }
34 | TenantContext.setTenantId(tenantId);
35 | }
36 |
37 | @Override
38 | public void postHandle(@NonNull WebRequest request, ModelMap model) {
39 | TenantContext.clear();
40 | }
41 |
42 | @Override
43 | public void afterCompletion(@NonNull WebRequest request, Exception ex) {
44 | // NOOP
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/util/TenantContext.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.multi_tenancy.util;
2 |
3 | import lombok.extern.slf4j.Slf4j;
4 |
5 | @Slf4j
6 | public final class TenantContext {
7 |
8 | private TenantContext() {}
9 |
10 | private static final InheritableThreadLocal currentTenant = new InheritableThreadLocal<>();
11 |
12 | public static void setTenantId(String tenantId) {
13 | log.debug("Setting tenantId to " + tenantId);
14 | currentTenant.set(tenantId);
15 | }
16 |
17 | public static String getTenantId() {
18 | return currentTenant.get();
19 | }
20 |
21 | public static void clear(){
22 | currentTenant.remove();
23 | }
24 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/repository/ProductRepository.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.repository;
2 |
3 | import org.springframework.data.repository.CrudRepository;
4 | import se.callista.blog.service.domain.entity.Product;
5 |
6 | public interface ProductRepository extends CrudRepository {
7 |
8 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/services/ProductService.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.services;
2 |
3 |
4 | import se.callista.blog.service.model.ProductValue;
5 |
6 | import java.util.List;
7 |
8 | public interface ProductService {
9 |
10 | List getProducts();
11 |
12 | ProductValue getProduct(long productId);
13 |
14 | ProductValue createProduct(ProductValue productValue);
15 |
16 | ProductValue updateProduct(ProductValue productValue);
17 |
18 | void deleteProductById(long productId);
19 | }
20 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/java/se/callista/blog/service/services/ProductServiceImpl.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.services;
2 |
3 | import org.springframework.beans.factory.annotation.Autowired;
4 | import org.springframework.stereotype.Component;
5 | import org.springframework.transaction.annotation.Transactional;
6 | import se.callista.blog.service.domain.entity.Product;
7 | import se.callista.blog.service.model.ProductValue;
8 | import se.callista.blog.service.repository.ProductRepository;
9 |
10 | import javax.persistence.EntityNotFoundException;
11 | import java.util.List;
12 | import java.util.stream.Collectors;
13 | import java.util.stream.StreamSupport;
14 |
15 | @Component
16 | public class ProductServiceImpl implements ProductService {
17 |
18 | private final ProductRepository productRepository;
19 |
20 | @Autowired
21 | public ProductServiceImpl(ProductRepository productRepository) {
22 | this.productRepository = productRepository;
23 | }
24 |
25 | @Override
26 | @Transactional(readOnly = true)
27 | public List getProducts() {
28 | return StreamSupport.stream(productRepository.findAll().spliterator(), false)
29 | .map(ProductValue::fromEntity)
30 | .collect(Collectors.toList());
31 | }
32 |
33 | @Override
34 | @Transactional(readOnly = true)
35 | public ProductValue getProduct(long productId) {
36 | return productRepository.findById(productId)
37 | .map(ProductValue::fromEntity)
38 | .orElseThrow(() -> new EntityNotFoundException("Product " + productId + " not found"));
39 | }
40 |
41 | @Override
42 | @Transactional
43 | public ProductValue createProduct(ProductValue productValue) {
44 | Product product = Product.builder()
45 | .name(productValue.getName())
46 | .build();
47 | product = productRepository.save(product);
48 | return ProductValue.fromEntity(product);
49 | }
50 |
51 | @Override
52 | @Transactional
53 | public ProductValue updateProduct(ProductValue productValue) {
54 | Product product = productRepository.findById(productValue.getProductId())
55 | .orElseThrow(() -> new EntityNotFoundException("Product " + productValue.getProductId() + " not found"));
56 | product.setName(productValue.getName());
57 | return ProductValue.fromEntity(product);
58 | }
59 |
60 | @Override
61 | @Transactional
62 | public void deleteProductById(long productId) {
63 | Product product = productRepository.findById(productId)
64 | .orElseThrow(() -> new EntityNotFoundException("Product " + productId + " not found"));
65 | productRepository.delete(product);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/resources/application.yml:
--------------------------------------------------------------------------------
1 | server:
2 | port: 8080
3 | spring:
4 | jpa:
5 | properties:
6 | hibernate:
7 | dialect: org.hibernate.dialect.PostgreSQLDialect
8 | hibernate:
9 | ddl-auto: none
10 | open-in-view: false
11 | multitenancy:
12 | master:
13 | datasource:
14 | url: jdbc:postgresql://localhost:5432/blog
15 | username: postgres
16 | password: secret
17 | liquibase:
18 | enabled: true
19 | changeLog: classpath:db/changelog/db.changelog-tenant.yaml
20 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/resources/db/changelog/db.changelog-tenant-1.0.yaml:
--------------------------------------------------------------------------------
1 | databaseChangeLog:
2 |
3 | - changeSet:
4 | id: product
5 | author: bjobes
6 | changes:
7 | - createSequence:
8 | sequenceName: product_seq
9 | startValue: 100000
10 | incrementBy: 50
11 | - createTable:
12 | tableName: product
13 | columns:
14 | - column:
15 | name: id
16 | type: BIGINT
17 | constraints:
18 | primaryKey: true
19 | primaryKeyName: branch_pkey
20 | - column:
21 | name: version
22 | type: INTEGER
23 | constraints:
24 | nullable: false
25 | defaultValueNumeric: 0
26 | - column:
27 | name: name
28 | type: VARCHAR(255)
29 | constraints:
30 | nullable: false
31 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/main/resources/db/changelog/db.changelog-tenant.yaml:
--------------------------------------------------------------------------------
1 | databaseChangeLog:
2 | - include:
3 | file: db/changelog/db.changelog-tenant-1.0.yaml
4 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/MultiTenantServiceApplicationTests.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import org.testcontainers.junit.jupiter.Container;
5 | import org.testcontainers.junit.jupiter.Testcontainers;
6 | import se.callista.blog.service.annotation.SpringBootIntegrationTest;
7 | import se.callista.blog.service.persistence.PostgresqlTestContainer;
8 |
9 | @Testcontainers
10 | @SpringBootIntegrationTest
11 | class MultiTenantServiceApplicationTests {
12 |
13 | @Container
14 | private static final PostgresqlTestContainer POSTGRESQL_CONTAINER = PostgresqlTestContainer.getInstance();
15 |
16 | @Test
17 | void contextLoads() {
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/annotation/SpringBootDbIntegrationTest.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.annotation;
2 |
3 | import com.github.database.rider.junit5.api.DBRider;
4 |
5 | import java.lang.annotation.ElementType;
6 | import java.lang.annotation.Retention;
7 | import java.lang.annotation.RetentionPolicy;
8 | import java.lang.annotation.Target;
9 |
10 | @SpringBootIntegrationTest
11 | @DBRider(dataSourceBeanName = "masterDataSource")
12 | @Retention(RetentionPolicy.RUNTIME)
13 | @Target(ElementType.TYPE)
14 | public @interface SpringBootDbIntegrationTest {
15 | }
16 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/annotation/SpringBootIntegrationTest.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.annotation;
2 |
3 | import org.junit.jupiter.api.Tag;
4 | import org.springframework.boot.test.context.SpringBootTest;
5 | import org.springframework.test.context.ActiveProfiles;
6 |
7 | import java.lang.annotation.ElementType;
8 | import java.lang.annotation.Retention;
9 | import java.lang.annotation.RetentionPolicy;
10 | import java.lang.annotation.Target;
11 |
12 | @SpringBootTest
13 | @Tag("integration")
14 | @ActiveProfiles("integration-test")
15 | @Retention(RetentionPolicy.RUNTIME)
16 | @Target(ElementType.TYPE)
17 | public @interface SpringBootIntegrationTest {
18 | }
19 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/controller/ProductApiControllerTest.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.controller;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import org.junit.jupiter.api.Test;
5 | import org.springframework.beans.factory.annotation.Autowired;
6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
7 | import org.springframework.boot.test.mock.mockito.MockBean;
8 | import org.springframework.context.annotation.Import;
9 | import org.springframework.test.web.servlet.MockMvc;
10 | import org.springframework.test.web.servlet.MvcResult;
11 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
12 | import se.callista.blog.service.model.ProductValue;
13 | import se.callista.blog.service.multi_tenancy.interceptor.TenantInterceptor;
14 | import se.callista.blog.service.services.ProductService;
15 |
16 | import static org.hamcrest.Matchers.is;
17 | import static org.mockito.BDDMockito.given;
18 | import static org.mockito.Mockito.verify;
19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
20 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
23 |
24 | @WebMvcTest(ProductApiController.class)
25 | @Import(TenantInterceptor.class)
26 | class ProductApiControllerTest {
27 |
28 | @Autowired
29 | private MockMvc mvc;
30 | @Autowired
31 | private ObjectMapper objectMapper;
32 |
33 | @MockBean
34 | private ProductService productService;
35 |
36 | @Test
37 | void getProduct() throws Exception {
38 |
39 | ProductValue product = ProductValue.builder()
40 | .productId(1L)
41 | .name("Product Name")
42 | .build();
43 |
44 | given(productService.getProduct(product.getProductId())).willReturn(product);
45 |
46 | mvc.perform(MockMvcRequestBuilders.get("/products/" + product.getProductId()))
47 | .andExpect(status().isOk())
48 | .andExpect(jsonPath("$.name", is(product.getName())));
49 |
50 | verify(productService).getProduct(product.getProductId());
51 | }
52 |
53 | @Test
54 | void asyncGetProduct() throws Exception {
55 |
56 | ProductValue product = ProductValue.builder()
57 | .productId(1L)
58 | .name("Product Name")
59 | .build();
60 |
61 | given(productService.getProduct(product.getProductId())).willReturn(product);
62 |
63 | MvcResult mvcResult = mvc.perform(MockMvcRequestBuilders.get("/async/products/" + product.getProductId()))
64 | .andExpect(request().asyncStarted())
65 | .andReturn();
66 |
67 | mvc.perform(asyncDispatch(mvcResult))
68 | .andExpect(status().isOk())
69 | .andExpect(jsonPath("$.name", is(product.getName())));
70 |
71 | verify(productService).getProduct(product.getProductId());
72 | }
73 |
74 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/persistence/PostgresqlTestContainer.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.persistence;
2 |
3 | import org.testcontainers.containers.PostgreSQLContainer;
4 |
5 | public class PostgresqlTestContainer extends PostgreSQLContainer {
6 | private static final String IMAGE_VERSION = "postgres:11.5";
7 | private static PostgresqlTestContainer container;
8 |
9 | private PostgresqlTestContainer() {
10 | super(IMAGE_VERSION);
11 | }
12 |
13 | public static PostgresqlTestContainer getInstance() {
14 | if (container == null) {
15 | container = new PostgresqlTestContainer();
16 | }
17 | return container;
18 | }
19 |
20 | @Override
21 | public void start() {
22 | super.start();
23 | System.setProperty("DB_NAME", container.getDatabaseName());
24 | System.setProperty("DB_HOST", container.getContainerIpAddress() + ":" + container.getMappedPort(POSTGRESQL_PORT));
25 | System.setProperty("DB_URL", container.getJdbcUrl());
26 | System.setProperty("DB_USERNAME", container.getUsername());
27 | System.setProperty("DB_PASSWORD", container.getPassword());
28 | }
29 |
30 | @Override
31 | public void stop() {
32 | //do nothing, JVM handles shut down
33 | }
34 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/repository/ProductRepositoryTest.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.repository;
2 |
3 | import com.github.database.rider.core.api.dataset.DataSet;
4 | import org.junit.jupiter.api.Test;
5 | import org.springframework.beans.factory.annotation.Autowired;
6 | import org.testcontainers.junit.jupiter.Container;
7 | import org.testcontainers.junit.jupiter.Testcontainers;
8 | import se.callista.blog.service.annotation.SpringBootDbIntegrationTest;
9 | import se.callista.blog.service.domain.entity.Product;
10 | import se.callista.blog.service.multi_tenancy.util.TenantContext;
11 | import se.callista.blog.service.persistence.PostgresqlTestContainer;
12 |
13 | import java.util.Optional;
14 |
15 | import static org.assertj.core.api.Assertions.assertThat;
16 |
17 | @Testcontainers
18 | @SpringBootDbIntegrationTest
19 | class ProductRepositoryTest {
20 |
21 | @Container
22 | private static final PostgresqlTestContainer POSTGRESQL_CONTAINER = PostgresqlTestContainer.getInstance();
23 |
24 | @Autowired
25 | private ProductRepository productRepository;
26 |
27 | @Test
28 | @DataSet(value = {"products.yml"})
29 | public void findByIdForTenant1() {
30 |
31 | TenantContext.setTenantId("tenant1");
32 | Optional product = productRepository.findById(1L);
33 | assertThat(product).isPresent();
34 | assertThat(product.get().getName()).isEqualTo("Product 1");
35 | TenantContext.clear();
36 |
37 | }
38 |
39 | @Test
40 | @DataSet(value = {"products.yml"})
41 | public void findByIdForTenant2() {
42 |
43 | TenantContext.setTenantId("tenant2");
44 | assertThat(productRepository.findById(1L)).isNotPresent();
45 | TenantContext.clear();
46 |
47 | }
48 |
49 | }
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/java/se/callista/blog/service/services/ProductServiceTest.java:
--------------------------------------------------------------------------------
1 | package se.callista.blog.service.services;
2 |
3 | import com.github.database.rider.core.api.dataset.DataSet;
4 | import org.junit.jupiter.api.Test;
5 | import org.springframework.beans.factory.annotation.Autowired;
6 | import org.testcontainers.junit.jupiter.Container;
7 | import org.testcontainers.junit.jupiter.Testcontainers;
8 | import se.callista.blog.service.annotation.SpringBootDbIntegrationTest;
9 | import se.callista.blog.service.model.ProductValue;
10 | import se.callista.blog.service.multi_tenancy.util.TenantContext;
11 | import se.callista.blog.service.persistence.PostgresqlTestContainer;
12 |
13 | import javax.persistence.EntityNotFoundException;
14 |
15 | import static org.assertj.core.api.Assertions.assertThat;
16 | import static org.junit.jupiter.api.Assertions.assertThrows;
17 |
18 | @Testcontainers
19 | @SpringBootDbIntegrationTest
20 | public class ProductServiceTest {
21 |
22 | @Container
23 | private static final PostgresqlTestContainer POSTGRESQL_CONTAINER = PostgresqlTestContainer.getInstance();
24 |
25 | @Autowired
26 | private ProductService productService;
27 |
28 | @Test
29 | @DataSet(value = {"products.yml"})
30 | public void getProductForTenant1() {
31 |
32 | TenantContext.setTenantId("tenant1");
33 | ProductValue product = productService.getProduct(1);
34 | assertThat(product.getName()).isEqualTo("Product 1");
35 | TenantContext.clear();
36 |
37 | }
38 |
39 | @Test
40 | @DataSet(value = {"products.yml"})
41 | public void getProductForTenant2() {
42 |
43 | TenantContext.setTenantId("tenant2");
44 | assertThrows(EntityNotFoundException.class, () -> productService.getProduct(1));
45 | TenantContext.clear();
46 |
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/resources/application-default.yml:
--------------------------------------------------------------------------------
1 | spring:
2 | profiles:
3 | active: test
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/resources/application-integration-test.yml:
--------------------------------------------------------------------------------
1 | spring:
2 | main:
3 | lazy-initialization: true
4 | banner-mode: "off"
5 | multitenancy:
6 | master:
7 | datasource:
8 | url: ${DB_URL}
9 | username: ${DB_USERNAME}
10 | password: ${DB_PASSWORD}
11 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/resources/application-test.yml:
--------------------------------------------------------------------------------
1 | spring:
2 | main:
3 | lazy-initialization: true
4 | banner-mode: "off"
5 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/resources/datasets/products.yml:
--------------------------------------------------------------------------------
1 | product:
2 | - id: 1
3 | name: Product 1
4 | - id: 2
5 | name: Product 2
6 |
--------------------------------------------------------------------------------
/multi-tenant-service/src/test/resources/dbunit.yml:
--------------------------------------------------------------------------------
1 | properties:
2 | caseSensitiveTableNames: true
3 | datatypeFactory: !!org.dbunit.ext.postgresql.PostgresqlDataTypeFactory {}
4 |
--------------------------------------------------------------------------------