Backend service (svc)

Project setup

Now that project structure is set up for the Onecx Quarkus project, we can configure the backend service and extend the project configuration.

Parent configuration

Project maven parent configuration, artifactId and project version.

More information about maven parent pom pattern Apache Maven Pom Documentation
Maven parent configuration
<parent>
    <groupId>org.tkit.onecx</groupId>
    <artifactId>onecx-quarkus3-parent</artifactId>
    <version>0.72.0</version>
</parent>

Dependencies

All version of the dependencies are defined in the parent project. Project itself should not define any version of these dependencies.

By default, we will have the following maven dependencies:

  • tkit-quarkus are quarkus extensions for JPA, log, json-log and rest-log

  • quarkus cloud native extensions (health, metrics, tracing, …​), quarkus-rest, hibernate-orm and liquibase

  • lombok to generate getters, setters and much more

  • mapstruct Generator to generate a source that maps DTO to JPA models and vice versa

Backend service default dependencies
<dependencies>
        <!-- ONECX -->

        <!-- 1000kit -->
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-rest-context</artifactId>
        </dependency>
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-log-cdi</artifactId>
        </dependency>
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-log-rs</artifactId>
        </dependency>
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-log-json</artifactId>
        </dependency>
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-rest</artifactId>
        </dependency>

        <!-- QUARKUS -->
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-arc</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-liquibase</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.blagerweij</groupId>
            <artifactId>liquibase-sessionlock</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-smallrye-health</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-micrometer-registry-prometheus</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-hibernate-orm</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-resteasy-reactive</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-resteasy-reactive-jackson</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-jdbc-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-smallrye-context-propagation</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-smallrye-openapi</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-hibernate-validator</artifactId>
        </dependency>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-opentelemetry</artifactId>
        </dependency>

        <!-- OTHER -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
        </dependency>
</dependencies>

Generate rest endpoint

In onecx project we do use API-first approach. First we need to defined our REST interface with openApi. Create a example-v1.yaml (pattern: <application>-<version>.yaml) in the src/main/openapi directory.

Example openapi file
---
openapi: 3.0.3
info:
  title: onecx-theme internal service
  version: 1.0.0
servers:
  - url: "http://onecx-theme-svc:8080"
tags:
  - name: themesInternal
paths:
  /internal/themes/search:
    post:
      security:
        - oauth2: [ ocx-th:all, ocx-th:read ]
      tags:
        - themesInternal
      description: Search for themes
      operationId: searchThemes
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ThemeSearchCriteria'
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ThemePageResult'
        "400":
          description: Bad Request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetailResponse'
components:
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://oauth.simple.api/token
          scopes:
            ocx-th:all: Grants access to all operations
            ocx-th:read: Grants read access
            ocx-th:write: Grants write access
            ocx-th:delete: Grants access to delete operations
  schemas:
    ThemeSearchCriteria:
      type: object
      properties:
        name:
          type: string
        pageNumber:
          format: int32
          description: The number of page.
          default: 0
          type: integer
        pageSize:
          format: int32
          description: The size of page
          default: 100
          maximum: 1000
          type: integer
    ThemePageResult:
      type: object
      properties:
        totalElements:
          format: int64
          description: The total elements in the resource.
          type: integer
        number:
          format: int32
          type: integer
        size:
          format: int32
          type: integer
        totalPages:
          format: int64
          type: integer
        stream:
          type: array
          items:
            $ref: '#/components/schemas/Theme'
    Theme:
      required:
        - name
        - displayName
      type: object
      properties:
        modificationCount:
          format: int32
          type: integer
        creationDate:
          $ref: '#/components/schemas/OffsetDateTime'
        creationUser:
          type: string
        modificationDate:
          $ref: '#/components/schemas/OffsetDateTime'
        modificationUser:
          type: string
        id:
          type: string
        name:
          minLength: 2
          type: string
        displayName:
          minLength: 2
          type: string
        cssFile:
          type: string
        description:
          type: string
        assetsUrl:
          type: string
        logoUrl:
          type: string
        faviconUrl:
          type: string
        previewImageUrl:
          type: string
        assetsUpdateDate:
          type: string
        properties:
          type: object
        operator:
          type: boolean
          default: false
    OffsetDateTime:
      format: date-time
      type: string
      example: 2022-03-10T12:15:50-04:00
    ProblemDetailResponse:
      type: object
      properties:
        errorCode:
          type: string
        detail:
          type: string
        params:
          type: array
          items:
            $ref: '#/components/schemas/ProblemDetailParam'
        invalidParams:
          type: array
          items:
            $ref: '#/components/schemas/ProblemDetailInvalidParam'
    ProblemDetailParam:
      type: object
      properties:
        key:
          type: string
        value:
          type: string
    ProblemDetailInvalidParam:
      type: object
      properties:
        name:
          type: string
        message:
          type: string

Now we can configure org.tkit.onecx.quarkus:onecx-openapi-generator Maven plugin. The important part of the Maven plugin is xml configuration element.

OpenApi generator full Maven plugin configuration
<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <configuration>
        <generatorName>jaxrs-spec</generatorName>
        <modelNameSuffix>DTO</modelNameSuffix>
        <generateApiTests>false</generateApiTests>
        <generateApiDocumentation>false</generateApiDocumentation>
        <generateModelTests>false</generateModelTests>
        <generateModelDocumentation>false</generateModelDocumentation>
        <generateSupportingFiles>false</generateSupportingFiles>
        <addCompileSourceRoot>true</addCompileSourceRoot>
        <library>quarkus</library>
        <additionalProperties>onecx-scopes=true</additionalProperties>
        <configOptions>
            <sourceFolder>/</sourceFolder>
            <openApiNullable>false</openApiNullable>
            <returnResponse>true</returnResponse>
            <useTags>true</useTags>
            <interfaceOnly>true</interfaceOnly>
            <serializableModel>true</serializableModel>
            <singleContentTypes>true</singleContentTypes>
            <dateLibrary>java8</dateLibrary>
            <useMicroProfileOpenAPIAnnotations>true</useMicroProfileOpenAPIAnnotations>
            <useJakartaEe>true</useJakartaEe>
            <useBeanValidation>true</useBeanValidation>
            <java17>true</java17>
        </configOptions>
    </configuration>
    <executions>
        <execution>
            <id>internal</id>
            <goals>
                <goal>generate</goal>
            </goals>
            <configuration>
                <inputSpec>src/main/openapi/onecx-theme-internal-openapi.yaml</inputSpec>
                <apiPackage>gen.org.tkit.onecx.theme.rs.internal</apiPackage>
                <modelPackage>gen.org.tkit.onecx.theme.rs.internal.model</modelPackage>
                <modelNameSuffix>DTO</modelNameSuffix>
            </configuration>
        </execution>
    </executions>
</plugin>

After we run mvn clean package maven plugin will generate in corresponding source code in target/generated-sources directory.

REST Implementation

To implement the RoleRestController we need to create a java class which implements generated interface gen.org.tkit.onecx.example.rs.v1.RoleInternalApi.

RoleRestController
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.Response;
import jakarta.validation.ConstraintViolationException;
import org.tkit.quarkus.jpa.exceptions.ConstraintException;
import jakarta.persistence.OptimisticLockException;

import gen.org.tkit.onecx.example.rs.v1.RoleInternalApi;
import gen.org.tkit.onecx.example.rs.v1.model.CreateRoleRequestDTO;

@ApplicationScoped
public class RoleRestController implements RoleInternalApi {

    @Inject
    RoleMapper mapper;

    @Inject
    ExceptionMapper exceptionMapper;

    @Context
    UriInfo uriInfo;

    @Override
    public Response createRole(CreateRoleRequestDTO createRoleRequestDTO) {
        var role = mapper.create(createRoleRequestDTO);
        role = dao.create(role);
        return Response
                .created(uriInfo.getAbsolutePathBuilder().path(role.getId()).build())
                .entity(mapper.map(theme))
                .build();
    }

    // constraint exception exception handler
    @ServerExceptionMapper
    public RestResponse<ProblemDetailResponseDTO> exception(ConstraintException ex) {
        return exceptionMapper.exception(ex);
    }

    // constraint violation exception handler
    @ServerExceptionMapper
    public RestResponse<ProblemDetailResponseDTO> constraint(ConstraintViolationException ex) {
        return exceptionMapper.constraint(ex);
    }

    // Optimistic lock exception handler
    @ServerExceptionMapper
    public RestResponse<ProblemDetailResponseDTO> optimisticLockException(OptimisticLockException ex) {
        return exceptionMapper.optimisticLock(ex);
    }

}

For the backend service we need to define exception mapper methods for following exception:

  • jakarta.persistence.OptimisticLockException - this exception is throw when try to store object in the database with old or wrong OPTLOCK number.

  • jakarta.validation.ConstraintViolationException - when we activate a validation framework io.quarkus:quarkus-hibernate-validator this exception will be thrown when request does not match to the requirements defined in the OpenApi.

  • org.tkit.quarkus.jpa.exceptions.ConstraintException - this exception will be thrown for any database constraints.

With the annotation @ServerExceptionMapper we defined the exception mapper for each Exception in the context of the RestController.

To map the request DTO object to the JPA model we use the Mapstruct. We define the RoleMapper in our example.

import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper;

@Mapper(uses = { OffsetDateTimeMapper.class })
public interface RoleMapper {

    @Mapping(target = "id", ignore = true)
    @Mapping(target = "creationDate", ignore = true)
    @Mapping(target = "creationUser", ignore = true)
    @Mapping(target = "modificationDate", ignore = true)
    @Mapping(target = "modificationUser", ignore = true)
    @Mapping(target = "controlTraceabilityManual", ignore = true)
    @Mapping(target = "modificationCount", ignore = true)
    @Mapping(target = "persisted", ignore = true)
    @Mapping(target = "tenantId", ignore = true)
    Role create(CreateRoleRequestDTO dto);

    // ... more mapping methods
}

The same approach we use for the exception mapper which could be shared between multiple RestController for the same rest interface.

import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper;

@Mapper(uses = { OffsetDateTimeMapper.class })
public interface ExceptionMapper {

    default RestResponse<ProblemDetailResponseDTO> constraint(ConstraintViolationException ex) {
        var dto = exception(ErrorKeys.CONSTRAINT_VIOLATIONS.name(), ex.getMessage());
        dto.setInvalidParams(createErrorValidationResponse(ex.getConstraintViolations()));
        return RestResponse.status(Response.Status.BAD_REQUEST, dto);
    }

    //.... more mapping methods
}
org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper mapper is predefined a mapstruct mapper for OffsetDateTime and LocalDateTime object.

JPA layer implementation

For our project we will use the tkit-quarkus-jpa and tkit-quarkus-jpa-models extension for Quarkus which have simple API on top of JPA. We have abstract DAO classes and abstract Entity classes which provides basic CRUD operation. For more information please read the extension tkit-quarkus-jpa documentation.

<dependency>
    <groupId>org.tkit.quarkus.lib</groupId>
    <artifactId>tkit-quarkus-jpa</artifactId>
</dependency>

Create User entity class in the org.tkit.onecx.user.domain.models package. The entity class extend the TraceableEntity. In the entity class can use the Lombok annotation @Getter and @Setter to generate the getters and setters.

Example user entity
import javax.persistence.Entity;
import javax.persistence.Table;
import org.tkit.quarkus.jpa.models.TraceableEntity;

@Entity
@Table(name = "T_USER")
public class User extends TraceableEntity {

    @Column(name = "USERNAME")
    private String username;
}
Example user DAO
import org.tkit.quarkus.jpa.daos.AbstractDAO;
import javax.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class UserDAO extends AbstractDAO<User> {

}

Database configuration

In onecx project we do use the postgresql database for our backend services.

quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.max-size=30
quarkus.datasource.jdbc.min-size=10

quarkus.hibernate-orm.database.generation=validate
quarkus.hibernate-orm.jdbc.timezone=UTC
quarkus.liquibase.migrate-at-start=true
quarkus.liquibase.validate-on-migrate=true
The parameter quarkus.hibernate-orm.log.sql will activate the hibernate logs in the console.

Database schema change management

Liquibase is an open source tool for database schema change management. We will this framework in our example. Liquibase does have abstraction layer xml, json or yaml to support multi databases. Liquibase can generate the changes and compare our Hibernate model with existing database.

For the database schema change management we do use quarkus-liquibase extension. To generate a database changes or validate the database changes we do use tkit-liquibase-plugin which is configured in the onecx-quarkus3-parent.

Quarkus liquibase configuration

quarkus.liquibase.migrate-at-start=true
quarkus.liquibase.validate-on-migrate=true

By default, liquibase does not use session lock. To activate the liquibase session lock during the start of our service we do use com.github.blagerweij:liquibase-sessionlock liquibase extension. No configuration is need to this extension.

To generate the database changes run following command

mvn clean compile -Pdb-diff

To check if you are missing any changes run following command

mvn validate -Pdb-check

JPA date-time

First, you need to configure the database server to use the UTC timezone. For example PostgreSQL default is UTC. Second, you need to set hibernate.jdbc.time_zone Hibernate property to the value of UTC. For the Quarkus application we need set key quarkus.hibernate-orm.jdbc.timezone to UTC value in the application.properties.

quarkus.hibernate-orm.jdbc.timezone=UTC

The date representation in the database is store in the UTC as long number. Date fields in the JPA entity represents this database date as LocalDateTime. On the another hand the DTO represents the date as ISO text. To get it running we need to create a mapper which will map the LocalDateTime to OffsetDatetime. In the mapping we should not lose the Offset/Zone information.

The date representation in the JPA entity is the LocalDateTime java type and in the DTO object is the OffsetDateTime java type.
Example user entity
@Entity
@Table(name = "T_USER")
public class User extends TraceableEntity {
    private String username;
    private LocalDateTime date;
}
Example user DTO
@RegisterForReflection
public class UserDTO extends TraceableDTO {
    private String username;
    private OffsetDateTime date;
}
Example user mapper
import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper;

@Mapper(uses = OffsetDateTimeMapper.class)
public interface UserMapper {

    UserDTO map(User model);

    User create(UserDTO dto);
}

In the tests we need to configure the RestAssured to use text representation of the date-time.

public abstract class AbstractTest {
    static {
        RestAssured.config = RestAssuredConfig.config().objectMapperConfig(
            ObjectMapperConfig.objectMapperConfig().jackson2ObjectMapperFactory(
                (cls, charset) -> {
                     ObjectMapper objectMapper = new ObjectMapper();
                     objectMapper.registerModule(new JavaTimeModule());
                     objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
                     return objectMapper;
                }
            )
        );
    }
}

JPA custom business ID

PostgresSQL SERIAL type

@GeneratorType(type = BidStringGenerator.class, when = GenerationTime.INSERT)
@Column(name = "CUSTOM")
String custom;

Generic sequence implementation

import javax.persistence.Column;
import org.my.group.BusinessId;

@BusinessId(sequence = "SEQ_MY_ENTITY_BID")
@Column(name = "bid_anno")
Long bid;

Follow the implementation of the custom annotation BusinessId.

BusinessId
import org.hibernate.annotations.ValueGenerationType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@ValueGenerationType(generatedBy = BusinessIdValueGeneration.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface BusinessId {

    String sequence() default "";

}
BusinessIdValueGeneration
import org.hibernate.tuple.AnnotationValueGeneration;
import org.hibernate.tuple.GenerationTiming;
import org.hibernate.tuple.ValueGenerator;

public class BusinessIdValueGeneration implements AnnotationValueGeneration<BusinessId> {

    String sequence;

    @Override
    public void initialize(BusinessId annotation, Class<?> propertyType) { sequence = annotation.sequence(); }

    @Override
    public GenerationTiming getGenerationTiming() { return GenerationTiming.INSERT; }

    @Override
    public ValueGenerator<?> getValueGenerator() { return new BusinessIdGenerator(sequence); }

    @Override
    public boolean referenceColumnInSql() { return false; }

    @Override
    public String getDatabaseGeneratedReferencedColumnValue() { return null; }
}
BusinessIdGenerator
import org.hibernate.Session;
import org.hibernate.internal.SessionFactoryImpl;
import org.hibernate.jdbc.ReturningWork;
import org.hibernate.tuple.ValueGenerator;

import java.sql.PreparedStatement;
import java.sql.ResultSet;


public class BusinessIdGenerator implements ValueGenerator<Long> {

    String sequence;

    BusinessIdGenerator(String sequence) {
        this.sequence = sequence;
    }

    @Override
    public Long generateValue(Session session, Object owner) {
        SessionFactoryImpl sessionFactory = (SessionFactoryImpl) session.getSessionFactory();
        String sql = sessionFactory.getJdbcServices().getDialect().getSequenceNextValString(sequence);
        ReturningWork<Long> seq = connection -> {
            try (PreparedStatement preparedStatement = connection.prepareStatement(sql);
                 ResultSet resultSet = preparedStatement.executeQuery()) {
                resultSet.next();
                return resultSet.getLong(1);
            }
        };
        return sessionFactory.getCurrentSession().doReturningWork(seq);
    }
}

We need to add the sequence to the import.sql for the local development.

CREATE SEQUENCE IF NOT EXISTS seq_my_entity_bid;

Custom in-Memory generator

@GeneratorType(type = BidStringGenerator.class, when = GenerationTime.INSERT)
@Column(name = "CUSTOM")
String custom;

Follow the implementation of the custom generator BidStringGenerator.

public class BidStringGenerator implements ValueGenerator<String> {

    @Override
    public String generateValue(Session session, Object owner) {
        MyEntity e = (MyEntity) owner;
        return e.name + "+" + UUID.randomUUID();
    }
}

Tests

For the test we add following dependencies to our project

Test dependencies
<dependencies>
    <!-- TEST -->
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-junit5-mockito</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.rest-assured</groupId>
        <artifactId>rest-assured</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.tkit.quarkus.lib</groupId>
        <artifactId>tkit-quarkus-test-db-import</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
The Quarkus tests has support for the injection and mocking. This will work only for the Quarkus JVM tests and not for native image test or integration tests with a docker image. Try to avoid this in our tests to have much more reusable tests. In most cases, the mock service also fakes code to pass the test.

For our test we will use this pattern for the test classes

  • <Name>RestControllerTest extends AbstractTest - the extended test for the common and unit test. For example: UserRestControllerTest

  • <Name>RestControllerTestIT extends <Name>RestControllerTest - the extended test for the integration test. For example: UserRestControllerTestIT

The 1000kit test extension tkit-quarkus-test-db-import does have support to import test data for the tests during the test execution. We will create our test data as XML data. Store the xml file in the src/test/resources/data directory under name test-internal.xml. The magic is in the @WithDBData annotation which could be used on the Class or Method level. Example of the xml test data file:

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <ROLE guid="r21" optlock="0" name="n1" />
    <ROLE guid="r22" optlock="0" name="n2" />
</dataset>
Only the XML DbUnit FlatXmlDataSet format is supported.

Example of the RoleRestControllerTest class.

@QuarkusTest
@TestHTTPEndpoint(RoleRestController.class)
@WithDBData(value = "data/test-internal.xml", deleteBeforeInsert = true, deleteAfterTest = true, rinseAndRepeat = true)
class RoleRestControllerTest extends AbstractTest {

    @Test
    void getRoleByIdTest() {

        // get role by ID
        var dto = given().contentType(APPLICATION_JSON).get("r12")
                .then().statusCode(OK.getStatusCode()).contentType(APPLICATION_JSON)
                .extract().body().as(RoleDTO.class);

        // validate role
        assertThat(dto).isNotNull();
        assertThat(dto.getName()).isEqualTo("n2");
        assertThat(dto.getId()).isEqualTo("r12");

        // not found
        given().contentType(APPLICATION_JSON).get("___").then().statusCode(NOT_FOUND.getStatusCode());
    }
}

Example of the role rest controller integration test.

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
class RoleRestControllerTestIT extends RoleRestControllerTest {

}

Security

To enable scope security for backend services we need to add these Maven dependencies.

<dependencies>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-oidc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.tkit.onecx.quarkus</groupId>
        <artifactId>onecx-security</artifactId>
    </dependency>
</dependencies>

The next step is to add the OAuth scopes to our OpenApi definition. In our example, we add the read scope to the createRole method.

Example openapi with scope
openapi: 3.0.3
info:
  title: example service
  version: 1.0.0
servers:
  - url: "http://onecx-example-svc:8080"
paths:
  /internal/roles:
    get:
      security:
        - oauth2: [read]
      operationId: createRole
      responses:
        201:
          description: "Role created"
components:
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://oauth.simple.api/token
          scopes:
            read: Grants read access

Now we need to add the extension org.tkit.onecx.quarkus:onecx-openapi-generator which will generate the Quarkus annotation @io.quarkus.security.PermissionsAllowed for the security interceptor from our open API definition file. We add this extension as a dependency for the Maven plugin org.openapitools:openapi-generator-maven-plugin.

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <configuration>
        <additionalProperties>onecx-scopes=true</additionalProperties>
        <!-- configuration -->
    </configuration>
    <executions><!-- executions --></executions>
    <dependencies>
        <dependency>
            <groupId>org.tkit.onecx.quarkus</groupId>
            <artifactId>onecx-openapi-generator</artifactId>
        </dependency>
    </dependencies>
</plugin>

To enable this extension, we also need to define additional properties onecx-scopes=true. Without this configuration, the generator uses the default Java source code template.

Now our generated source code contains the annotation @io.quarkus.security.PermissionsAllowed({"read"}) to the generated method.

public interface RoleInternalApi {

    @io.quarkus.security.PermissionsAllowed({ "read" })
    @GET
    @Consumes({ "application/json" })
    @Produces({ "application/json" })
    Response createRole();

}

The org.tkit.onecx.quarkus:onecx-security extension creates security audit data for the default Quarkus security extension during the build process. After enabling this extension, it is no longer possible to call this method without a token and with the scope read.

To disable security setup following property tkit.security.auth.enabled=false. This property is part fo (tkit-quarkus-url)[1000kit Quarkus extension]

Security tests

For testing we use Quarkus Keycloak dev services. To activate this feature add this maven dependency.

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-keycloak-server</artifactId>
    <scope>test</scope>
</dependency>

Quarkus will start and configure a Keycloak server for our tests but also in the dev mode by default. Now we can configure user and they roles. For example, we add role role-admin to user alice in our example

# Enabled security for all requests /* and exclude /q/* quarkus console
quarkus.http.auth.permission.health.paths=/q/*
quarkus.http.auth.permission.health.policy=permit
quarkus.http.auth.permission.default.paths=/*
quarkus.http.auth.permission.default.policy=authenticated

# TEST
%test.quarkus.keycloak.devservices.roles.alice=role-admin
%test.quarkus.keycloak.devservices.roles.bob=role-user

# DEV
%dev.quarkus.keycloak.devservices.roles.alice=role-admin
%dev.quarkus.keycloak.devservices.roles.bob=role-user
How to import the whole realm or configure additional user in to Keycloak dev services please check the Quarkus documentation page.

Now we can use the io.quarkus.test.keycloak.client.KeycloakTestClient in our test.

Example test class
@QuarkusTest
@TestHTTPEndpoint(RoleRestController.class)
class RoleRestControllerTest {

    // create keycloak client instance connected to keycloak dev service container
    KeycloakTestClient keycloakClient = new KeycloakTestClient();

     @Test
    void oauthTest() {
         // get the access token of the user alice
        var token = keycloakClient.getAccessToken("alice");

        // call rest endpoint
        given().when()
                .auth().oauth2(token)
                .contentType(APPLICATION_JSON).get("1").then()
                .statusCode(Response.Status.OK.getStatusCode());

        // test without oauth token
        given().when().contentType(APPLICATION_JSON).get("1").then()
                .statusCode(Response.Status.UNAUTHORIZED.getStatusCode());
    }
}

Multi tenancy

For multi tenancy we will to use onecx-quarkus-tenant extension.

For more information about the configuration please check the documentation page onecx-quarkus-tenant
Multi tenancy maven dependencies
<dependencies>
        <!-- ONECX -->
        <dependency>
            <groupId>org.tkit.onecx.quarkus</groupId>
            <artifactId>onecx-tenant</artifactId>
        </dependency>
        <!-- 1000kit -->
        <dependency>
            <groupId>org.tkit.quarkus.lib</groupId>
            <artifactId>tkit-quarkus-jpa</artifactId>
        </dependency>
</dependencies>

Now we need to configure our application

# hibernate multi-tenant configuration
quarkus.hibernate-orm.multitenant=DISCRIMINATOR

# enable or disable multi-tenancy support
tkit.rs.context.tenant-id.enabled=true
Header configuration for the token, and token parsing is implemented in 1000kit tkit-rest-context quarkus extensions. Please check the documentation of the extension.

Multi tenancy tests

For the test we can use mock settings where we can define which claim attribute of the token we will use: %test.tkit.rs.context.tenant-id.mock.claim-org-id=orgId and we must define mapping between token claim value and tenant ID %test.tkit.rs.context.tenant-id.mock.data.<claim-value>=<tenant-id>.

%test.tkit.rs.context.tenant-id.enabled=true
%test.tkit.rs.context.tenant-id.mock.enabled=true
%test.tkit.rs.context.tenant-id.mock.default-tenant=default
%test.tkit.rs.context.tenant-id.mock.claim-org-id=orgId
%test.tkit.rs.context.tenant-id.mock.data.org1=tenant-100
%test.tkit.rs.context.tenant-id.mock.data.org2=tenant-200

For the test we can create a simple help method which will creates a token for our tests

@SuppressWarnings("java:S2187")
public class AbstractTest {

    // header ID of the principal token for tests
    protected static final String APM_HEADER_PARAM = "apm-principal-token";

    // token claim for tenant-id
    protected static final String CLAIMS_ORG_ID = ConfigProvider.getConfig()
            .getValue("%test.tkit.rs.context.tenant-id.mock.claim-org-id", String.class);

   /**
    * Method creates a principal token for test.
    * @param organizationId organization ID
    * @return the corresponding test
    */
    protected static String createToken(String organizationId) {
        try {
            String userName = "test-user";
            JsonObjectBuilder claims = Json.createObjectBuilder();
            claims.add(Claims.preferred_username.name(), userName);
            claims.add(Claims.sub.name(), userName);
            claims.add(CLAIMS_ORG_ID, organizationId);
            PrivateKey privateKey = KeyUtils.generateKeyPair(2048).getPrivate();
            return Jwt.claims(claims.build()).sign(privateKey);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }
}

In our test we can use createToken method to create a token

@QuarkusTest
@TestHTTPEndpoint(ExampleRestController.class)
class ExampleRestControllerTenantTest extends AbstractTest {

    @Test
    void createNewThemeTest() {
        var dto = given()
                .when()
                .contentType(APPLICATION_JSON)
                .header(APM_HEADER_PARAM, createToken("org1"))
                .body(themeDto)
                .post()
                .then()
                .statusCode(CREATED.getStatusCode())
                .extract()
                .body().as(ExampleDTO.class);
    }
}