Skip to content

Commit

Permalink
feat: Adding dynamic database selection support to Panache MongoDb
Browse files Browse the repository at this point in the history
It supports multi-tenancy implementation following database per tenant approach, It consists in a interface called MongoDatabaseResolver that can be implemented to get the database name to be used on mongo operations execution. So if I have a MongoDatabaseResolver implementation the database to be used in the current mongo operation will be the return of resolve() method, but the resolver will be successful only for the entities that doesn't specify the database through the @MongoEntity(database="")

Example:
There is a "CustomMongoDatabaseResolver", a insert operation is executing, during mongo database selection the resolve method will be called and if it have returned "customerA", the selected mongo database used to execute this operation was "customerA"
  • Loading branch information
pedroh-pereira committed Nov 28, 2022
1 parent ce25570 commit 8d6c731
Show file tree
Hide file tree
Showing 13 changed files with 644 additions and 4 deletions.
352 changes: 351 additions & 1 deletion docs/src/main/asciidoc/mongodb-panache.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ The `quarkus.mongodb.database` property will be used by MongoDB with Panache to
The `@MongoEntity` annotation allows configuring:

* the name of the client for multi-tenant application, see xref:mongodb.adoc#multiple-mongodb-clients[Multiple MongoDB Clients]. Otherwise, the default client will be used.
* the name of the database, otherwise, the `quarkus.mongodb.database` property will be used.
* the name of the database, otherwise the `quarkus.mongodb.database` property or an link:{mongodb-doc-root-url}#multitenancy[[`MongoDatabaseResolver`] implementation will be used.
* the name of the collection, otherwise the simple name of the class will be used.

For advanced configuration of the MongoDB client, you can follow the xref:mongodb.adoc#configuring-the-mongodb-database[Configuring the MongoDB database guide].
Expand Down Expand Up @@ -1238,3 +1238,353 @@ by the presence of the marker file `META-INF/panache-archive.marker`. Panache in
annotation processor that will automatically create this file in archives that depend on
Panache (even indirectly). If you have disabled annotation processors you may need to create
this file manually in some cases.

[[multitenancy]]
== Multitenancy

"Multitenancy is a software architecture where a single software instance can serve multiple, distinct user groups. Software-as-a-service (SaaS) offerings are an example of multitenant architecture." (link:https://www.redhat.com/en/topics/cloud-computing/what-is-multitenancy#:~:text=Multitenancy%20is%20a%20software%20architecture,an%20example%20of%20multitenant%20architecture.[Red Hat]).

MongoDB Panache currently supports the database per tenant approach, it's similar to schema per tenant approach when compared to SQL databases.

=== Writing the application

Let's start by implementing the `Person` resource. As you can see from the source code below it is just a regular JAX-RS resource:

[source,java]
----
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
@ApplicationScoped
@Path("/persons")
public class PersonResource {
@Inject
PersonRepository repository;
@Inject
ReactivePersonRepository reactiveRepository;
@POST
public Person addPerson(Person person) {
return repository.persist(person);
}
@GET
@Path("/{id}")
public Person findById(String id) {
return repository.findById(new ObjectId(id));
}
@DELETE
@Path("/{id}")
public void deletePerson(String id) {
Person person = repository.findById(Long.parseLong(id));
return repository.delete(new ObjectId(id));
}
@POST
public Uni<Person> addPersonReactive(Person person) {
return reactiveRepository.persist(person);
}
@GET
@Path("/{id}")
public Uni<Person> findByIdReactive(String id) {
return reactiveRepository.findById(new ObjectId(id));
}
@DELETE
@Path("/{id}")
public Uni<Void> deletePersonReactive(String id) {
return reactivePersonRepository.findById(Long.parseLong(id))
.flatMap(person -> reactivePersonRepository.delete(person));
}
}
----

NOTE: We won't describe the examples for `Person`, `PersonRepository` and `ReactivePersonRepository` to avoid repetition, it was described in previous sections;

[IMPORTANT]
====
Please be careful when you need to execute some Mongo operation in async way using some source not managed by `smallrye` like `CompleatableFeature` methods. In this case you need to propagate the thread context, see xref:context-propagation.adoc#[Context propagation in Quarkus].
====

In order to resolve the tenant from incoming requests and map it to a specific database, you need to create an implementation for the `io.quarkus.mongodb.panache.common.MongoDatabaseResolver` interface.

[source,java]
----
import javax.enterprise.context.RequestScoped;
import io.quarkus.arc.Unremovable;
import io.quarkus.mongodb.panache.common.MongoDatabaseResolver;
import io.vertx.ext.web.RoutingContext;
@Unremovable // <1>
@RequestScoped // <2>
public class CustomMongoDatabaseResolver implements MongoDatabaseResolver {
@Inject
RoutingContext context;
@Override
public String resolve() {
return context.request().getHeader("X-Tenant");
}
}
----

<1> Annotate the MongoDatabaseResolver implementation with the `@Unremovable` qualifier
to tell Quarkus it must not be auto removed, because behind the scenes it's not being injected using `@Inject`

<2> The bean is made `@RequestScoped` as the tenant resolution depends on the incoming request.

[NOTE]
====
You can also ignore the steps `1` and `2` in favor to provide this bean via @Dependent annotaded class and produces this unremovable bean, for example:
[source,java]
----
import javax.enterprise.context.RequestScoped;
import io.quarkus.arc.Unremovable;
import io.quarkus.mongodb.panache.common.MongoDatabaseResolver;
import io.vertx.ext.web.RoutingContext;
@Dependent
public class MongoConfig {
@Unremovable
@RequestScoped
public MongoDatabaseResolver mongoDatabaseResolver(RoutingContext context) {
return new CustomMongoDatabaseResolver(context);
}
}
----
====

From the implementation above, tenants are resolved from the request header, so that in case no tenant could be inferred, the default tenant identifier is returned.

[NOTE]
====
If you also use xref:security-openid-connect-multitenancy.adoc[OIDC multitenancy] and OIDC tenantID and MongoDB database are the same and must be extracted from the Vert.x `RoutingContext` then you can pass the tenant id from the OIDC `TenantResolver` to the MongoDB Panache `MongoDatabaseResolver` as a `RoutingContext` attribute, for example:
[source,java]
----
import io.quarkus.mongodb.panache.common.MongoDatabaseResolver;
import io.vertx.ext.web.RoutingContext;
@Unremovable
@RequestScoped
public class CustomMongoDatabaseResolver implements MongoDatabaseResolver {
@Inject
RoutingContext context;
...
@Override
public String resolve() {
// OIDC TenantResolver has already calculated the tenant id and saved it as a RoutingContext `tenantId` attribute:
return context.get("tenantId");
}
}
----
====

=== Configuring the application

The same mongo instance will be used for all tenants and a database has to be created for every tenant inside that instance.

[source,properties]
----
quarkus.mongodb.connection-string=mongodb://login:pass@mongo:27017/database
# The default database
quarkus.mongodb.database=persons
----

IMPORTANT: The database selection priority order is followed by: database attribute from `@MongoEntity`, `MongoDatabaseResolver` and then `quarkus.mongodb.database` property

=== Testing

You can write your test like this:

[source,java]
----
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.mongodb.MongoReplicaSetTestResource;
import io.restassured.RestAssured;
import io.restassured.config.ObjectMapperConfig;
import io.restassured.http.Method;
import io.restassured.parsing.Parser;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
@QuarkusTest
@QuarkusTestResource(MongoReplicaSetTestResource.class)
@DisabledOnOs(OS.WINDOWS)
public class MongoDatabaseResolverTest {
private static final String TENANT_1 = "tenant-1";
private static final String TENANT_2 = "tenant-2";
private static final Long ID_PERSON_1 = Long.valueOf(1);
private static final Long ID_PERSON_2 = Long.valueOf(2);
private static final Map<String, Object> PERSON_1;
private static final Map<String, Object> PERSON_2;
private static final ObjectMapper OBJ_MAPPER;
static {
PERSON_1 = new HashMap<>();
PERSON_1.put("id", ID_PERSON_1);
PERSON_1.put("firstname", "Pedro");
PERSON_1.put("lastname", "Pereira");
PERSON_2 = new HashMap<>();
PERSON_2.put("id", ID_PERSON_2);
PERSON_2.put("firstname", "Tibé");
PERSON_2.put("lastname", "Venâncio");
OBJ_MAPPER = new ObjectMapper()
.registerModule(new Jdk8Module())
.registerModule(new JavaTimeModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
@Test
public void testMongoDatabaseResolverUsingPersonResourceImperative() {
testMongoDatabaseResolverUsingPersonResource(false);
}
@Test
public void testMongoDatabaseResolverUsingPersonResourceReactive() {
testMongoDatabaseResolverUsingPersonResource(true);
}
private void testMongoDatabaseResolverUsingPersonResource(boolean isReactive) {
Person person1 = OBJ_MAPPER.convertValue(PERSON_1, Person.class);
Person person2 = OBJ_MAPPER.convertValue(PERSON_2, Person.class);
String endpoint = String.format("%s/persons/repository/", isReactive ? "/reactive" : "");
_testMongoDatabaseResolverUsingPersonResource(endpoint, person1, person2);
}
private void _testMongoDatabaseResolverUsingPersonResource(String endpoint, Object person1, Object person2) {
RestAssured.defaultParser = Parser.JSON;
RestAssured.config
.objectMapperConfig(new ObjectMapperConfig().jackson2ObjectMapperFactory((type, s) -> OBJ_MAPPER));
// creating person 1
Response createPerson1Response = callCreatePersonEndpoint(endpoint, TENANT_1, person1);
assertResponse(createPerson1Response, 201);
// checking person 1 creation
Response getPerson1ByIdResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_1, TENANT_1);
assertResponse(getPerson1ByIdResponse, 200, person1);
// creating person 2
Response createPerson2Response = callCreatePersonEndpoint(endpoint, TENANT_2, person2);
assertResponse(createPerson2Response, 201);
// checking person 2 creation
Response getPerson2ByIdResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_2, TENANT_2);
assertResponse(getPerson2ByIdResponse, 200, person2);
// trying to get person 1 passing wrong tenants
Response getPerson1ByIdPassingWrongTenantResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_1,
TENANT_2);
assertResponse(getPerson1ByIdPassingWrongTenantResponse, 204);
Response getPerson1ByIdNotSendingTenantHeaderResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_1, null);
assertResponse(getPerson1ByIdNotSendingTenantHeaderResponse, 204);
// trying to get person 2 passing wrong tenants
Response getPerson2ByIdPassingWrongTenantResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_2,
TENANT_1);
assertResponse(getPerson2ByIdPassingWrongTenantResponse, 204);
Response getPerson2ByIdNotSendingTenantHeaderResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_2, null);
assertResponse(getPerson2ByIdNotSendingTenantHeaderResponse, 204);
// deleting person 1
Response deletePerson1Response = callDeletePersonByIdEndpoint(endpoint, ID_PERSON_1, TENANT_1);
assertResponse(deletePerson1Response, 204);
// checking person 1 deletation
Response getPerson1ByIdEmptyResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_1, TENANT_1);
assertResponse(getPerson1ByIdEmptyResponse, 204);
// deleting person 2
Response deletePerson2Response = callDeletePersonByIdEndpoint(endpoint, ID_PERSON_2, TENANT_2);
assertResponse(deletePerson2Response, 204);
// checking person 2 deletation
Response getPerson2ByIdEmptyResponse = callGetPersonByIdEndpoint(endpoint, ID_PERSON_2, TENANT_2);
assertResponse(getPerson2ByIdEmptyResponse, 204);
}
protected Response callCreatePersonEndpoint(String endpoint, String tenant, Object person) {
return RestAssured.given()
.header("Content-Type", "application/json")
.header(CustomMongoDatabaseResolver.TENANT_HEADER_NAME, tenant)
.body(person)
.when()
.post(endpoint)
.andReturn();
}
private Response callDeletePersonByIdEndpoint(String endpoint, Long resourceId, String tenant) {
return callPersonByIdEndpoint(endpoint, Method.DELETE, resourceId, tenant);
}
private Response callGetPersonByIdEndpoint(String endpoint, Long resourceId, String tenant) {
return callPersonByIdEndpoint(endpoint, Method.GET, resourceId, tenant);
}
private Response callPersonByIdEndpoint(String endpoint, Method method, Long resourceId, String tenant) {
RequestSpecification request = RestAssured.given()
.header("Content-Type", "application/json");
if (Objects.nonNull(tenant) && !tenant.isBlank()) {
request.header(CustomMongoDatabaseResolver.TENANT_HEADER_NAME, tenant);
}
return request.when()
.request(method, endpoint.concat("{id}"), resourceId)
.andReturn();
}
private void assertResponse(Response response, Integer expectedStatusCode) {
assertResponse(response, expectedStatusCode, null);
}
private void assertResponse(Response response, Integer expectedStatusCode, Object expectedResponseBody) {
assertEquals(expectedStatusCode, response.statusCode());
if (Objects.nonNull(expectedResponseBody)) {
assertTrue(EqualsBuilder.reflectionEquals(response.as(expectedResponseBody.getClass()), expectedResponseBody));
}
}
}
----
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ A similar technique can be used with `TenantConfigResolver` where a `tenant-id`

[NOTE]
====
If you also use xref:hibernate-orm.adoc#multitenancy[Hibernate ORM multitenancy] and both OIDC and Hibernate ORM tenant IDs are the same and must be extracted from the Vert.x `RoutingContext` then you can pass the tenant id from the OIDC Tenant Resolver to the Hibernate ORM Tenant Resolver as a `RoutingContext` attribute, for example:
If you also use xref:hibernate-orm.adoc#multitenancy[Hibernate ORM multitenancy] or xref:mongodb-panache.adoc#multitenancy[MongoDB Panache multitenancy] and both tenant IDs are the same and must be extracted from the Vert.x `RoutingContext` then you can pass the tenant id from the OIDC Tenant Resolver to the Hibernate ORM Tenant Resolver or MongoDB Panache Mongo Database Resolver as a `RoutingContext` attribute, for example:
[source,java]
----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.quarkus.mongodb.panache.common;

/*
* This interface can be used to resolve the MongoDB database name at runtime, it allows to implement multi-tenancy using a tenant per database approach.
*/
public interface MongoDatabaseResolver {
String resolve();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static io.quarkus.mongodb.panache.common.runtime.BeanUtils.beanName;
import static io.quarkus.mongodb.panache.common.runtime.BeanUtils.clientFromArc;
import static io.quarkus.mongodb.panache.common.runtime.BeanUtils.getDatabaseName;
import static io.quarkus.mongodb.panache.common.runtime.BeanUtils.getDatabaseNameFromResolver;

import java.util.ArrayList;
import java.util.Arrays;
Expand Down Expand Up @@ -315,7 +316,7 @@ private ReactiveMongoDatabase mongoDatabase(MongoEntity mongoEntity) {
if (mongoEntity != null && !mongoEntity.database().isEmpty()) {
return mongoClient.getDatabase(mongoEntity.database());
}
String databaseName = getDefaultDatabaseName(mongoEntity);
String databaseName = getDatabaseNameFromResolver().orElse(getDefaultDatabaseName(mongoEntity));
return mongoClient.getDatabase(databaseName);
}

Expand Down
Loading

0 comments on commit 8d6c731

Please sign in to comment.