Flowable provides a set of Spring Boot starters to help you embed the different engines (i.e., BPMN, DMN and CMMN) and to expose their RESTful APIs.

In this post, I'll walk you through the steps I followed to create a RESTful API (Resource Server) that embeds Flowable's BPMN engine, exposes the BPMN engine's RESTful API and leverages Spring Security’s support for OAuth 2.0 and Jason Web Tokens (JWTs).

Spring Boot

Getting Started

According to the Spring Boot Getting Started guide you should use the Spring Initializr to bootstrap your application:

I added the following dependencies: Spring Boot Starter Security, Spring Boot Starter Web, Spring Security OAuth2 Resource Server, Spring Security OAuth2 JOSE (Javascript Object Signing and Encryption), Spring Security Config, Spring Boot Starter HAETOAS, Evo Inflector, Spring Boot Starter Data Rest, Spring Boot Starter Data JPA, H2 Database and Lombok.

Flowable

In order to embed Flowable's BPMN engine and expose the BPMN engine's RESTful API we need to update the project's pom.xml:

  <dependencies>
  
    ...

    <dependency>
      <groupId>org.flowable</groupId>
      <artifactId>flowable-spring-boot-starter-app</artifactId>
      <version>${flowable.version}</version>
    </dependency>

    <dependency>
      <groupId>org.flowable</groupId>
      <artifactId>flowable-spring-configurator</artifactId>
      <version>${flowable.version}</version>
    </dependency>

    <dependency>
      <groupId>org.flowable</groupId>
      <artifactId>flowable-spring-boot-starter-process-rest</artifactId>
      <version>${flowable.version}</version>
    </dependency>

    <dependency>
      <groupId>org.flowable</groupId>
      <artifactId>flowable-form-spring-configurator</artifactId>
      <version>${flowable.version}</version>
    </dependency>

    <dependency>
      <groupId>org.flowable</groupId>
      <artifactId>flowable-ldap</artifactId>
      <version>${flowable.version}</version>
    </dependency>
    
    <!--
    <dependency>
      <groupId>org.flowable</groupId>
      <artifactId>flowable-spring-boot-starter-rest</artifactId>
      <version>${flowable.version}</version>
    </dependency>
    -->    
   
  </dependencies>

By default, certain folders on the classpath are automatically scanned:

  • /apps: Looks for all files ending with .bar and deploys them
  • /cases: Looks for all files ending with .cmmn, .cmmn11, .cmmn.xml or .cmmn11.xml and deploys them
  • /dmn: Looks for all files ending with .dmn, .dmn11, .dmn.xml or .dmn12.xml and deploys them
  • /forms: Looks for all files ending with .form and deploys them
  • /processes: Looks for all files ending with .bpmn20.xml or .bpmn and deploys them

For example:

├── /src
     └── /main
           └── /java
           └── /resources
                 └── /apps
                       ├── hr-app.bar
                 └── /processes
           ├── application.properties
     └── /test

Spring Security

I extended Spring Security's WebSecurityConfigurerAdapter:

package org.serendipity.restapi.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;
import java.util.Collections;

@EnableWebSecurity
@Profile({"dev", "test", "prod"})
@Slf4j
public class DefaultSecurityConfig extends WebSecurityConfigurerAdapter {

  @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
  private String jwkSetUri;

  @Override
  protected void configure(HttpSecurity http) throws Exception {

    http.cors().and()
      .authorizeRequests()
      .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
      .antMatchers("/h2-console/**").permitAll()
      .antMatchers("/docs/**").permitAll()
      .anyRequest().authenticated();

    http.csrf().ignoringAntMatchers("/h2-console/**");
    http.headers().frameOptions().sameOrigin();

    http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
  }

  @Bean
  JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build();
  }

  @Bean
  CorsConfigurationSource corsConfigurationSource() {

    CorsConfiguration configuration = new CorsConfiguration();

    configuration.applyPermitDefaultValues();
    configuration.setAllowedOrigins(Collections.singletonList("*"));
    configuration.setAllowedMethods(Arrays.asList("POST", "GET", "PATCH", "DELETE"));
    configuration.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);

    return source;
  }

}

I updated the application.properties:

spring.profiles.active=@spring.profiles.active@

spring.main.banner-mode=off
spring.jpa.open-in-view=false

server.port=3001

spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:10001/auth/realms/development/protocol/openid-connect/certs

spring.data.rest.base-path=/api

# Logging
logging.level.root=INFO
logging.level.org.flowable=WARN
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.springframework.security=WARN

To point to my Authorization Server's JWK Set Uri.

For example:

curl http://localhost:10001/auth/realms/development/protocol/openid-connect/certs

Sample output:

{
    "keys": [
        {
            "kid": "5E-jGIlDdpgjnhJzbdS3XXeZWZCmRUB85Snfp5IwyjI",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "t3609odH5dIcPZUd4yFTjilR89MigdbgwnzFII82hzeazIsZivoSVWTfWG4620-zYN5PyFPKsaW_0IF7MPCeDXgRZ0odiizRQdhczZQA-zVrlqxy93SqRpjqRd_F3bOmwQZAsdepQefGNrTIArpq62s5ycUQTf-qbzsnCQugb5_SRa7u1VJaBgb0jTM-L304TSiW1vUFg2Th4fyQqVL4xrJuBPrrJCBs9nx9GPJDD8fXZtDMLlXRYhzZjW8zfqHSyA46JCDbK4-Tt6Ra6dNNKz8n2leSbUcf9NgBsiGHn23SnkM5GzbKbI-i_oQTvueP2psZzm8_Oyi3KYLtjNwBWw",
            "e": "AQAB",
            "x5c": [
                "MIICrTCCAZUCBgFvIWzNBjANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9TZXJlbmRpcGl0eSBDRVAwHhcNMTkxMjIwMDM0NzU2WhcNMjkxMjIwMDM0OTM2WjAaMRgwFgYDVQQDDA9TZXJlbmRpcGl0eSBDRVAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3frT2h0fl0hw9lR3jIVOOKVHz0yKB1uDCfMUgjzaHN5rMixmK+hJVZN9YbjrbT7Ng3k/IU8qxpb/QgXsw8J4NeBFnSh2KLNFB2FzNlAD7NWuWrHL3dKpGmOpF38Xds6bBBkCx16lB58Y2tMgCumrraznJxRBN/6pvOycJC6Bvn9JFru7VUloGBvSNMz4vfThNKJbW9QWDZOHh/JCpUvjGsm4E+uskIGz2fH0Y8kMPx9dm0MwuVdFiHNmNbzN+odLIDjokINsrj5O3pFrp000rPyfaV5JtRx/02AGyIYefbdKeQzkbNspsj6L+hBO+54/amxnObz87KLcpgu2M3AFbAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAFVnIzwaPDVTvQ3q1QRczCBG348SqpDV1rrh5omMTwiqDI6uVWqgz7ij4I6XkS2w3B+eccxecE5cio0ZkRoxT1Ft4gZt5rzOIVch0nGIJp7NgTd5OKXE3BfJ0hcji+QPMosB7VKBWC82zu/n13DLoNzVn0qPUvh/1cvg/3cjDE5WKqPzaJleoRSdQhATMo5PyyqRkNVaqObuFWJffrErJNRFfeFXUFQvstSxcOmdKkuGZcMlLKbfug5qfAHK+0xCL/nOCMZizEhnAZqA7qzXqaM7NMUg48XN6x0z1Vxz5IDDfpVlN9TbDnNiSGiPJLENY5rm31kDjm9iHp4FrHF8FzI="
            ],
            "x5t": "bHB0yas9UeCu3AzfcE0Uu8omPhY",
            "x5t#S256": "otHl46Z-oZbRfHnTezn5gtr1rjHUhmWTvGYV3g_q8lc"
        }
    ]
}

Now we can build the application:

mvn clean
mvn package

And launch it:

java -jar target/serendipity-rest-api-core-0.0.1-SNAPSHOT.jar

We can use Postman to obtain an access token from Keycloak:

We can also use Postman to test the application's RESTful API:

package org.serendipity.restapi.controller;

import lombok.extern.slf4j.Slf4j;
import org.serendipity.restapi.assembler.IndividualModelAssembler;
import org.serendipity.restapi.entity.Individual;
import org.serendipity.restapi.model.IndividualModel;
import org.serendipity.restapi.repository.IndividualRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.rest.webmvc.BasePathAwareController;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.PagedModel;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.net.URI;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@BasePathAwareController
@Slf4j
public class IndividualController extends Controller<Individual, IndividualRepository, IndividualModelAssembler> {

  public IndividualController(IndividualRepository repository,
                              IndividualModelAssembler assembler,
                              PagedResourcesAssembler<Individual> pagedResourcesAssembler) {

    super(repository, assembler, pagedResourcesAssembler);
  }

  @GetMapping("/individuals")
  @PreAuthorize("hasAuthority('SCOPE_individual:read')")
  public ResponseEntity<PagedModel<IndividualModel>> findAll(
    Pageable pageable) throws ResponseStatusException {

    try {

      Page<Individual> entities = repository.findAll(pageable);
      PagedModel<IndividualModel> models = pagedResourcesAssembler.toModel(entities, assembler);

      return ResponseEntity.ok(models);

    } catch (Exception e) {

      log.error("{}", e.getLocalizedMessage());

      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }

  }

  @GetMapping("/individuals/{id}")
  @PreAuthorize("hasAuthority('SCOPE_individual:read')")
  public ResponseEntity<IndividualModel> findById(
      @PathVariable("id") final Long id) throws ResponseStatusException {

    try {

      Individual entity = repository.findById(id).orElseThrow(() ->
        new ResponseStatusException(HttpStatus.NOT_FOUND));

      IndividualModel model = assembler.toModel(entity);

      logInfo(entity, model);

      return ResponseEntity.ok(model);

    } catch (Exception e) {

      log.error("{}", e.getLocalizedMessage());

      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }

  }

  @GetMapping("/individuals/search/findByFamilyNameStartsWith")
  @PreAuthorize("hasAuthority('SCOPE_individual:read')")
  public ResponseEntity<PagedModel<IndividualModel>> findByFamilyNameStartsWith(
    @RequestParam("name") final String name, Pageable pageable) throws ResponseStatusException {

    try {

      Page<Individual> entities = repository.findByNameFamilyNameStartsWith(name, pageable);
      PagedModel<IndividualModel> models = pagedResourcesAssembler.toModel(entities, assembler);

      return ResponseEntity.ok(models);

    } catch (Exception e) {

      log.error("{}", e.getLocalizedMessage());

      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }

  }

  @PostMapping("/individuals")
  @PreAuthorize("hasAuthority('SCOPE_individual:post')")
  public ResponseEntity<IndividualModel> create(
    @RequestBody Individual individual) throws ResponseStatusException {

    try {

      Individual entity = repository.save(individual);
      IndividualModel model = assembler.toModel(entity);

      logInfo(entity, model);

      return ResponseEntity.created(linkTo(methodOn(IndividualController.class).findById(entity.getId())).toUri()).body(model);

    } catch (Exception e) {

      log.error("{}", e.getLocalizedMessage());

      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }

  }

  @PatchMapping("/individuals/{id}")
  @PreAuthorize("hasAuthority('SCOPE_individual:patch')")
  public ResponseEntity<IndividualModel> update(
    @PathVariable("id") final Long id, @RequestBody Individual individual) throws ResponseStatusException {

    try {

      individual.setId(id);

      repository.save(individual);

      Link link = linkTo(methodOn(IndividualController.class).findById(id)).withSelfRel();

      return ResponseEntity.noContent().location(new URI(link.getHref())).build();

    } catch (Exception e) {

      log.error("{}", e.getLocalizedMessage());

      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }

  }

  @DeleteMapping("/individuals/{id}")
  @PreAuthorize("hasAuthority('SCOPE_individual:delete')")
  public ResponseEntity<IndividualModel> delete(
    @PathVariable("id") final Long id) throws ResponseStatusException {

    try {

      repository.deleteById(id);

      return ResponseEntity.noContent().build();

    } catch (Exception e) {

      log.error("{}", e.getLocalizedMessage());

      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }

  }

}

For example:

And to test the BPMN engine's RESTful API:

We can also navigate to the H2 console:

Flowable UI Applications

  • Flowable Identity Management
  • Flowable Modeler
  • Flowable Task
  • Flowable Admin

You can download the Flowable open source distribution from the Flowable web site.

Externalised Configuration

The Flowable Web applications take advantage of Spring Boot's support for externalised configuration:

spring.main.banner-mode=off
spring.jpa.open-in-view=false

# Logging
logging.level.root=INFO
logging.level.org.flowable=WARN
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.springframework.security=WARN

# Spring Datasource - Postgres
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/serendipity
spring.datasource.username=admin
spring.datasource.password=secret

# Spring JPA - Postgres
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update

# Spring Datasource - H2
# spring.datasource.driver-class-name=org.h2.Driver
# spring.datasource.url=jdbc:h2:~/serendipity-db/db;AUTO_SERVER=TRUE;AUTO_SERVER_PORT=9091;DB_CLOSE_DELAY=-1
# spring.datasource.username=admin
# spring.datasource.password=secret

# Spring JPA - H2
# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# spring.jpa.hibernate.ddl-auto=update

# H2 Console
# spring.h2.console.enabled=false
# spring.h2.console.path=/h2-console
# spring.h2.console.settings.trace=false
# spring.h2.console.settings.web-allow-others=false

# Default Flowable Admin Accounts - see: flowable.ldif
flowable.idm.app.admin.user-id=flowable
flowable.idm.app.admin.password=test
flowable.idm.app.admin.first-name=
flowable.idm.app.admin.last-name=Administrator
flowable.idm.app.admin.email=admin@serendipity.org.au

flowable.common.app.idm-admin.user=flowable
flowable.common.app.idm-admin.password=test

flowable.modeler.app.deployment-api-url=http://localhost:9999/flowable-task/app-api

# LDAP
flowable.idm.ldap.enabled=true
flowable.idm.ldap.server=ldap://localhost
flowable.idm.ldap.port=389
flowable.idm.ldap.user=cn=admin,dc=flowable,dc=org
flowable.idm.ldap.password=secret
flowable.idm.ldap.base-dn=dc=flowable,dc=org
flowable.idm.ldap.user-base-dn=ou=users,dc=flowable,dc=org
flowable.idm.ldap.group-base-dn=ou=groups,dc=flowable,dc=org
flowable.idm.ldap.query.user-by-id=(&(objectClass=inetOrgPerson)(uid={0}))
flowable.idm.ldap.query.user-by-full-name-like=(&(objectClass=inetOrgPerson)(|({0}=*{1}*)({2}=*{3}*)))
flowable.idm.ldap.query.all-users=(objectClass=inetOrgPerson)
flowable.idm.ldap.query.groups-for-user=(&(objectClass=groupOfUniqueNames)(uniqueMember={0}))
flowable.idm.ldap.query.all-groups=(objectClass=groupOfUniqueNames)
flowable.idm.ldap.query.group-by-id=(&(objectClass=groupOfUniqueNames)(uniqueId={0}))
flowable.idm.ldap.attribute.user-id=uid
flowable.idm.ldap.attribute.first-name=cn
flowable.idm.ldap.attribute.last-name=sn
flowable.idm.ldap.attribute.email=mail
flowable.idm.ldap.attribute.group-id=cn
flowable.idm.ldap.attribute.group-name=cn
flowable.idm.ldap.cache.group-size=10000
flowable.idm.ldap.cache.group-expiration=180000

Flowable Identity Management

To launch Flowable's Identity Management application:

java -jar flowable-idm.war

Then navigate to: http://localhost:8080/flowable-idm

Flowable Modeler

To launch Flowable's Modeler application:

java -jar flowable-modeler.war

Then navigate to: http://localhost:8888/flowable-modeler

Flowable Task

To launch Flowable's Task application:

java -jar flowable-task.war

Then navigate to: http://localhost:9999/flowable-task

Flowable Admin

To launch Flowable's Admin application:

java -jar flowable-admin.war

Then navigate to: http://localhost:9988/flowable-admin

Database Driver

The Flowable UI application wars include the H2 database driver. If you want to use a different database then you need to update each war file, for example:

unzip flowable-idm.war
mv postgresql-42.2.14.jar WEB-INF/lib
jar uf0 flowable-idm.war WEB-INF/lib/postgresql-42.2.14.jar

unzip flowable-task.war
mv postgresql-42.2.14.jar WEB-INF/lib
jar uf0 flowable-task.war WEB-INF/lib/postgresql-42.2.14.jar
Source Code:
References: