Sunday, 9 June 2024

Implementing Dynamic Groovy Script Execution in a Spring Boot Application

This post is a continuation of my previous one, where I explained how to execute a Groovy Script in Java. In this post, I will demonstrate how to build a Spring Boot application that stores scripts in an in-memory database, processes given input data with the stored script, and returns the result to the end user.

How to persist the groovy scripts?

 


We can use persistent stores like MySQL, Cassandra, etc., to save Groovy scripts. For this demonstration, I am using an in-memory database (H2), and the table structure is as follows.

create table GROOVYSCRIPT (
    id integer generated by default as identity,
    name varchar(255) unique,
    script nvarchar(10000),
    primary key (id)
);

id: System-generated identifier

name: Unique script name

script: Field to store the Groovy script.

 

How to run the script on given input payload?

To simplify the process, I expect the Groovy script to include a function with the following structure.

def process(Map<String, Object> inputData) {
    // User-defined functionality goes here
}

Users must define the functionality they want to apply to the input data within the process method. The Groovy engine will then take the input payload and the script's name or ID, execute the specified script using the provided input data, and return the final result to the user.



In this section, we will look at how to execute a Groovy script on given input data within a Spring Boot application. The following Java method does the magic.

public static final String SCRIPT_TEMPLATE = """ 
    def inputMap = input as Map<String, Object>
    return process(inputMap)
    """;

public Object executeScript(@Valid ScriptExecRequestDto dto) {
    Optional<GroovyScript> optionalScript = scriptRepository.findById(dto.getScriptId());
    if (optionalScript.isPresent()) {
        Binding binding = new Binding();
        binding.setVariable("input", dto.getInput());

        // Set an initial value for result variable
        binding.setVariable("result", "");

        GroovyShell shell = new GroovyShell(binding);
        StringBuilder builder = new StringBuilder();
        builder.append(AppConstants.SCRIPT_TEMPLATE);
        builder.append(optionalScript.get().getScript());
        return shell.evaluate(builder.toString());

    } else {
        throw new NoSuchElementException("Script not found");
    }
}

public static final String SCRIPT_TEMPLATE = """ 
    def inputMap = input as Map<String, Object>
    return process(inputMap)
""";

This template defines a basic Groovy script that casts the input object to a Map<String, Object> and then calls a process method with this map as its argument. The process method is expected to be defined in the user-provided Groovy script. The result of this method is returned as the output.

 

executeScript Method

public Object executeScript(@Valid ScriptExecRequestDto dto) {
    Optional<GroovyScript> optionalScript = scriptRepository.findById(dto.getScriptId());
    if (optionalScript.isPresent()) {
        Binding binding = new Binding();
        binding.setVariable("input", dto.getInput());

        // Set an initial value for result variable
        binding.setVariable("result", "");

        GroovyShell shell = new GroovyShell(binding);
        StringBuilder builder = new StringBuilder();
        builder.append(AppConstants.SCRIPT_TEMPLATE);
        builder.append(optionalScript.get().getScript());
        return shell.evaluate(builder.toString());

    } else {
        throw new NoSuchElementException("Script not found");
    }
}

The method starts by fetching the Groovy script from the database using the script ID provided in the ScriptExecRequestDto. The Binding object is used to bind variables that are accessible within the Groovy script. Here, we bind the input data from the DTO to the variable input. A GroovyShell instance is created with the binding. The script to be executed is composed by appending the template script (SCRIPT_TEMPLATE) and the user-provided script. This ensures that the process method from the user script is executed with the provided input data. The shell.evaluate method runs the combined script and returns the result.

 

Find the below working application.

 

Step 1: Create new maven project ‘groovy-engine’.

 

Step 2: Update pom.xml with maven dependencies.

 

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.sample.app</groupId>
    <artifactId>groovy-engine</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.2</version>
    </parent>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.0.4</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.0.4</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

        <dependency>
            <groupId>org.codehaus.groovy</groupId>
            <artifactId>groovy-all</artifactId>
            <version>3.0.21</version>
            <type>pom</type>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Step 3: Define ScriptExecRequestDto class.

 

ScriptExecRequestDto.java

package com.sample.app.dto;

import java.util.Map;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;

public class ScriptExecRequestDto {
    
    @Positive
    private Integer scriptId;
    
    @NotEmpty
    private Map<String, Object> input;

    public Integer getScriptId() {
        return scriptId;
    }

    public void setScriptId(Integer scriptId) {
        this.scriptId = scriptId;
    }

    public Map<String, Object> getInput() {
        return input;
    }

    public void setInput(Map<String, Object> input) {
        this.input = input;
    }

}

Step 4: Define GroovyScript entity class.

 

GroovyScript.java

package com.sample.app.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "GROOVYSCRIPT")
public class GroovyScript {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "name", unique = true)
    private String name;

    @Column(name = "script", columnDefinition = "nvarchar(10000)")
    private String script;

    // Getters and setters

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getScript() {
        return script;
    }

    public void setScript(String script) {
        this.script = script;
    }

    public static GroovyScript fromScriptAndName(String script, String name) {
        GroovyScript groovySript = new GroovyScript();
        groovySript.setName(name);
        groovySript.setScript(script);
        return groovySript;
    }
}

Step 5: Define repository interface.

 

GroovyScriptRepository.java

package com.sample.app.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.sample.app.entity.GroovyScript;

public interface GroovyScriptRepository extends JpaRepository<GroovyScript, Integer>{

}

Step 6: Define AppConstants class.

 

AppConstants.java

package com.sample.app.constants;

public class AppConstants {
    
    public static final String SCRIPT_TEMPLATE = """ 
            def inputMap = input as Map<String, Object>
            return process(inputMap)
            """;

}

Step 7: Define swagger configuration.

 

OpenApi3Config.java

package com.sample.app.config;

import org.springframework.context.annotation.Configuration;

import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.servers.Server;

@Configuration
@OpenAPIDefinition(info = @Info(title = "Groovy service", version = "v1"), servers = {
        @Server(url = "http://localhost:8080", description = "local server") })
public class OpenApi3Config {

}

Step 8: Define GroovyService class.

 

GroovyService.java

package com.sample.app.service;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import com.sample.app.constants.AppConstants;
import com.sample.app.dto.ScriptExecRequestDto;
import com.sample.app.entity.GroovyScript;
import com.sample.app.repository.GroovyScriptRepository;

import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import jakarta.validation.Valid;

@Service
public class GroovyService {

    @Autowired
    private GroovyScriptRepository scriptRepository;

    public List<GroovyScript> getAllScripts() {
        return scriptRepository.findAll();
    }

    public GroovyScript byFile(MultipartFile scriptFile, String name) throws IOException {
        MultipartFile file = scriptFile;
        InputStream inputStream = file.getInputStream();
        String sript = convertToString(inputStream);
        GroovyScript groovyScript = GroovyScript.fromScriptAndName(sript, name);
        return scriptRepository.save(groovyScript);

    }

    public Object executeScript(@Valid ScriptExecRequestDto dto) {
        Optional<GroovyScript> optionalScript = scriptRepository.findById(dto.getScriptId());
        if (optionalScript.isPresent()) {
            Binding binding = new Binding();
            binding.setVariable("input", dto.getInput());

            // Set an initial value for result variable
            binding.setVariable("result", "");

            GroovyShell shell = new GroovyShell(binding);
            StringBuilder builder = new StringBuilder();
            builder.append(AppConstants.SCRIPT_TEMPLATE);
            builder.append(optionalScript.get().getScript());
            return shell.evaluate(builder.toString());

        } else {
            throw new NoSuchElementException("Script not found");
        }
    }

    private static String convertToString(InputStream in) throws IOException {
        ByteArrayOutputStream result = new ByteArrayOutputStream();
        byte[] buffer = new byte[1024];
        int length;
        while ((length = in.read(buffer)) != -1) {
            result.write(buffer, 0, length);
        }
        return result.toString("UTF-8");
    }

}

Step 9: Define GroovyScriptController class.

 

GroovyScriptController.java

package com.sample.app.controller;

import java.io.IOException;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.sample.app.dto.ScriptExecRequestDto;
import com.sample.app.entity.GroovyScript;
import com.sample.app.service.GroovyService;

import jakarta.validation.constraints.NotEmpty;

@RestController
@RequestMapping("/api/scripts")
public class GroovyScriptController {

    @Autowired
    private GroovyService groovyService;

    @GetMapping
    public List<GroovyScript> getAllScripts() {
        return groovyService.getAllScripts();
    }

    @PostMapping("/execute")
    public ResponseEntity<Object> executeScript(@RequestBody ScriptExecRequestDto dto) {
        Object result = groovyService.executeScript(dto);

        return ResponseEntity.ok(result);
    }

    @PostMapping(value = "/by-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<GroovyScript> byFile(@RequestPart("name") @NotEmpty String name,
            @RequestPart("script") MultipartFile scriptFile) throws IOException {
        GroovyScript groovyScript = groovyService.byFile(scriptFile, name);
        return ResponseEntity.status(201).body(groovyScript);
    }
}

 

Step 10: Create application.yml file under src/main/resources folder.

 

application.yml

 

spring:   
  datasource:
    communicationtimeout: 60000
    jpa:
      hibernate:
        ddl-auto: none
    hikari:
      connection-timeout: 250000
      idle-timeout: 300000
      max-lifetime: 1000000
      maximum-pool-size: 20
  jpa:
    hibernate:
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    database-platform:  org.hibernate.dialect.H2Dialect 
---
spring:
  jpa:
    properties:
      hibernate:
        show_sql: true
        format_sql: false

Step 11: Define main application class.

 

App.java

package com.sample.app;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {

  public static void main(String[] args) throws Exception {

    SpringApplication.run(App.class, args);
  }

}

Total project structure looks like below.



Build the project

Navigate to the folder where pom.xml file is located and execute the command 'mvn package'. Upon successful execution of the command, you can see a jar file ‘groovy-engine-0.0.1-SNAPSHOT.jar’ in target folder.

 

Run the Application

Execute below command to run the application.

java -jar ./target/groovy-engine-0.0.1-SNAPSHOT.jar

 

Experiment with Swagger end point

Open the url ‘http://localhost:8080/swagger-ui/index.html’ to work with swagger.

 

FullName Converter

Let’s write a groovy script, that gets the fullName by concatenating firstName and lastName values.

 

FullNameConverter.groovy

// Sample Groovy script to transform firstName and lastName to fullName
def process(Map<String, Object> map) {
    def firstName = map.get("firstName")
    def lastName = map.get("lastName")
    def fullName = "$firstName $lastName"
    return fullName
}



Use the API 'http://localhost:8080/api/scripts/by-file' to upload the groovy script. You will see below kind of response.

{
  "id": 1,
  "name": "FullNameConverter",
  "script": "// Sample Groovy script to transform firstName and lastName to fullName\ndef process(Map<String, Object> map) {\n    def firstName = map.get(\"firstName\")\n    def lastName = map.get(\"lastName\")\n    def fullName = \"$firstName $lastName\"\n    return fullName\n}"
}

Let’s apply FullNameConverter to sample payload

Call the API 'http://localhost:8080/api/scripts/execute' with below payload.

{
  "scriptId": 1,
  "input": {
    "firstName": "Krishna",
    "lastName": "Gurram"
  }
}

You will see the response as 'Krishna Gurram'

 

You can try with below script that converts map values to uppercase.

ValuesToUppercase.groovy

// Sample Groovy script to convert map values to uppercase
def process(Map<String, Object> map) {
    map.collectEntries { key, value -> [key, value.toString().toUpperCase()] }
}

Sample Input

{
  "scriptId": 2,
  "input": {
    "1": "One",
    "2": "Two"
  }
}

Expected Output

{
  "1": "ONE",
  "2": "TWO"
}

Note

1.   We can improvise the performance of this application, by storing the compiled byte code (so we no need to recompile the groovy script everytime) and caching it.

2.   Deploying scripts carries inherently lower risk compared to deploying servers because the impact of script deployments is confined to specific classes or subsets of devices. This allows for greater agility and flexibility.

 

You can download this application from this link.


                                                                                System Design Questions

No comments:

Post a Comment