Software Architectures logo Software Architectures

Contents

Export Students to FTP

You want to integrate in your Students App an external service to do some analytics. This service, unfortunately, is not managed directly by you and does not have API endpoints to communicate with. The external agency that manages this service told you “don’t worry, just put every students in a folder in a FTP and we manage the import on our side. Students must be in a json format, one student per file”. How we can do this? Let’s see an example with Camel.

Requirements

Before writing code, we should sit and think about the overall export process. It is required to:

services:
  web:
    build: .
    restart: always
    ports:
      - "8080:8080"
    depends_on:
      - students_db
      - studentsftp
    environment:
      - SPRING_DATASOURCE_URL=jdbc:mysql://students_db:3306/studentsapp?createDatabaseIfNotExist=true
      - CAMEL_FTP_URI=ftp://studentsapp@studentsftp:21/students/export?password=stUd3nts@pp&passiveMode=true
  students_db:
    volumes:
      - students-db:/var/lib/mysql
    image: "mysql:5.7"
    restart: always
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=secret_password
  studentsftp:
    volumes:
      - students-ftp:/ftp/studentsapp
    image: "delfer/alpine-ftp-server"
    ports:
      - "21:21"
      - "21000-21010:21000-21010"
    environment:
      - USERS=studentsapp|stUd3nts@pp
volumes:
  students-db:
  students-ftp:

Note that ftp name is without the underscore: this is because seems that Camel has some difficulties to handle hostname with underscore.

Changes to our database table

We want to save the export status of a student: to do this, we can add a column in our student model:

package com.example.studentsapp.models;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import org.springframework.hateoas.RepresentationModel;
@Entity // Entity means that this class must be threatened as a relational entity.
@Table( name = "Students" ) // This is used by the ORM to link this Entity in a DBMS Table.
public class Student extends RepresentationModel<Student> {
    @Column // this tells ORM to map this attribute (name) to a table field with same name. You can specify the name of the field inside the database by using @Column(name="db_column_name")
    private String name;
    @Column
    private String surname;
    @Id // Id means that this field is the Primary Key of the table.
    @Column
    private String id;

    @Column
    @JsonIgnore
    private Boolean exported = false;
    [...]
}

Note the @JsonIgnore annotation: this means that this attribute is hidden and not shown in REST responses. We sets a default value to false (not exported), but we need also to sets the flag on the existing students. We can do this by running the application (such that Hibernate can create the column in our table) and then perform an UPDATE using Dbeaver:

Gradle dependencies

Let’s add Camel dependencies to our build.gradle:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.hibernate.orm:hibernate-core:6.2.6.Final")
    implementation("mysql:mysql-connector-java:8.0.30")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-hateoas")
    implementation("org.apache.camel:camel-jdbc:4.0.0")
    implementation("org.apache.camel:camel-jackson:4.0.0")
    implementation("org.apache.camel:camel-ftp:4.0.0")
    implementation("org.apache.camel:camel-bean:4.0.0")
    implementation("org.apache.camel.springboot:camel-spring-boot-starter:4.0.0")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

Export model

We want to model the Student to a specific Class for export: we want to keep separate the models used by the Application from the model used by the Export: this is because it is easy to add attributes or perform changes on the model without affecting the application. Our ExportStudent Model can be something like this (note that this is just a POJO: no annotations, no extensions, …):

package com.example.studentsapp.models;

import java.util.Date;

public class ExportStudent {
    private String name;
    private String surname;
    private String id;
    private Date exportDate;
    public String getName() {
        return name;
    }

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

    public String getSurname() {
        return surname;
    }

    public void setSurname(String surname) {
        this.surname = surname;
    }

    public String getId() {
        return id;
    }

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

    public Date getExportDate() {
        return exportDate;
    }

    public void setExportDate(Date exportDate) {
        this.exportDate = exportDate;
    }



    public ExportStudent(String id, String name, String surname) {
        this.surname = surname;
        this.name = name;
        this.id = id;
        setExportDate();
    }

    private void setExportDate() {
        this.exportDate = new Date();
    }
}

Camel Route

What remain to do is to define our Camel Route.

package com.example.studentsapp.camel;

import com.example.studentsapp.models.ExportStudent;

import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;

import org.apache.camel.component.jackson.JacksonDataFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.sql.DataSource;

@Component
public class StudentsExport extends RouteBuilder {
    @Autowired
    DataSource dataSource;

    @Value("${camel.ftp.uri}")
    private String ftpURI;
    private StudentConverter mapper = new StudentConverter();

    @Override
    public void configure() throws Exception {

        JacksonDataFormat jsonDataFormat = new JacksonDataFormat(ExportStudent.class);
        from("timer:simple?period=60000")
        .setBody(constant("select * from students where exported=FALSE"))
        .to("jdbc:dataSource")
        .choice()
            .when(simple("${body.isEmpty()}"))
                .log("No students to export...")
            .otherwise()
            .split(body())
            .bean(mapper, "toStudent")
            .setHeader("StudentID", simple("${body.id}"))
            .setHeader("CamelFileName", simple("${body.id}.json"))
            .process(this::log)
            .marshal(jsonDataFormat)
            .to(ftpURI)
            .setBody(simple("UPDATE students SET exported = TRUE where id='${header.StudentID}'"))
            .to("jdbc:dataSource")
        .end();
    }

    private void log(Exchange exchange) {
        System.out.println("Exporting student " + ((ExportStudent)exchange.getIn().getBody()).getId());
    }
}

Let’s spend some words here: we annotated the class as a Spring Component: in this way, our Route is automatically launched with the application. In the configure() method, all the logic is defined: every 60000 milliseconds (every minute), a SELECT will be performed in our database (database hostname, username and password is taken direcly from application.properties autowiring the dataSource). Then we made a choice (it is like an if): if the body is empty (i.e., no students is returned by the query), we log this fact (log) and we exit the pipeline. Otherwise, we split the body (because we could have more than one students to process) and for every student we call StudentConverter.toStudent: this is a custom bean that permits to parse the HashMap returned by the jdbc camel connector to a Student. The implementation of StudentConverter is easy: just create a new java file in studentsapp.camel package with this content:

package com.example.studentsapp.camel;

import com.example.studentsapp.models.ExportStudent;
import org.springframework.stereotype.Component;

import java.util.LinkedHashMap;

@Component
public class StudentConverter {

    public ExportStudent toStudent(LinkedHashMap<String, Object> map) {
        return new ExportStudent((String)map.get("id"), (String)map.get("name"), (String)map.get("surname"));
    }
}

Next, we set some headers: we want to keep track of the student id and of the name of the FTP file (studentId.json). Then, we call a process function (in this case, we just log something, but we can do something more complex). We convert the student in a JSON and we export it to the ftp (to(ftpUri)). ftpUri is a class attribute, and the @Value(“${camel.ftp.uri}”) annotation means that the value of this attribute must be taken from the camel.ftp.uri property (defined as an environment variable in our container, or also be taken from application.properties):

spring.devtools.restart.enabled=false
server.port=8080
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.datasource.url=jdbc:mysql://localhost:3306/studentsapp?createDatabaseIfNotExist=true
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=update
spring.datasource.username=root
spring.datasource.password=secret_password
camel.ftp.uri=ftp://studentsapp@localhost:21/students/export?password=stUd3nts@pp&passiveMode=true

Note that in application.properties the hostname of FTP (and of the MySQL server) is localhost: this is needed if we want to test directly the application without docker (in this case, we are not in the same network). Container’s environment variable overrides the value of these properties. Then we UPDATE the student setting the exported flag as true. Camel Example 1 Camel Example 2

Testing

Let’s test the integration: remove the container and the image of the web service and:

~/IdeaProjects/StudentsApp ./gradlew clean        

BUILD SUCCESSFUL in 2s
1 actionable task: 1 executed
~/IdeaProjects/StudentsApp ./gradlew build -x test

> Task :compileJava
Note: /Users/giacomo/IdeaProjects/StudentsApp/src/main/java/com/example/studentsapp/repositories/StudentRepository.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

BUILD SUCCESSFUL in 2s
4 actionable tasks: 4 executed
~/IdeaProjects/StudentsApp docker compose up -d   
[+] Building 2.6s (7/7) FINISHED                                                                                                                                                                                                docker:desktop-linux
 => [web internal] load build definition from Dockerfile                                                                                                                                                                                        0.0s
 => => transferring dockerfile: 176B                                                                                                                                                                                                            0.0s
 => [web internal] load .dockerignore                                                                                                                                                                                                           0.0s
 => => transferring context: 2B                                                                                                                                                                                                                 0.0s
 => [web internal] load metadata for docker.io/library/eclipse-temurin:17-jdk-alpine                                                                                                                                                            1.4s
 => [web internal] load build context                                                                                                                                                                                                           0.7s
 => => transferring context: 58.42MB                                                                                                                                                                                                            0.7s
 => CACHED [web 1/2] FROM docker.io/library/eclipse-temurin:17-jdk-alpine@sha256:e890b4f91ec8aa40f1537a50a53ab516fc42341c3b5dd608d1aeee2b1cba55b1                                                                                               0.0s
 => [web 2/2] COPY build/libs/*.jar app.jar                                                                                                                                                                                                     0.1s
 => [web] exporting to image                                                                                                                                                                                                                    0.3s
 => => exporting layers                                                                                                                                                                                                                         0.3s
 => => writing image sha256:b78076a1c79d98322b3bc4bd73ce01c0fa28bd427c139e7b5760a0b0469661c3                                                                                                                                                    0.0s
 => => naming to docker.io/library/studentsapp-web                                                                                                                                                                                              0.0s
[+] Running 4/4
 ✔ Container studentsapp-studentsftp-1        Running                                                                                                                                                                                           0.0s 
 ✔ Container studentsapp-students_rabbitmq-1  Running                                                                                                                                                                                           0.0s 
 ✔ Container studentsapp-students_db-1        Running                                                                                                                                                                                           0.0s 
 ✔ Container studentsapp-web-1                Started                                                                                                                                                                                           0.4s 
~/IdeaProjects/StudentsApp 

Camel Example 3 Camel Example 4 Camel Example 5

Let’s add a new Student: Camel Example 6 Camel Example 7 Camel Example 8 Camel Example 9 Camel Example 10 We can also connect to our FTP Server using an FTP Client like, for example, FileZilla: Camel Example 11

Previous: Camel - Introduction