Gluon CloudLink Function Mapper App is a Gluon code sample. Refer to the Gluon website for a full list of Gluon code samples.

In this tutorial, you will learn to integrate external data in your JavaFX application by configuring Remote Functions in Gluon CloudLink. The desktop code calls a function with parameters. This function makes a request to Gluon CloudLink, where the Remote Function will then call the corresponding external endpoint, providing the required parameters, and will return an observable object to the desktop application.

We will create a JavaFX application using FXML based JavaFX Maven Archetype and edit it as per our use case. The application will use Maven plugin for JavaFX to compile and run the application.

To define the remote functions and also to create the client application with a service that calls those functions, we will use the Gluon Dashboard.

We will use a StackOverflow endpoint, based on its Stack Exchange API v2.2. As shown in the following picture, the application will talk only to Gluon CloudLink to do searches based on some keywords. Using that API, Gluon CloudLink will perform the request to StackOverflow, and it will process the JSON response, finally returning a JavaFX observable object to the desktop application. Within the Dashboard, the developer will be able to create the functions with the required endpoints and parameters (keywords).

Overview

Before you start, be sure that you have checked the list of prerequisites.

Code: The code for this project can be found in the samples repository at GitHub. The sample is located under the directory cloudlink-function-mapper-desktop. The reader can clone this repository or create the entire project from scratch, based on the following steps.

Gluon Dashboard

For this sample, you’ll need a valid subscription to Gluon CloudLink. If you don’t have it yet, get it from here (there is also a 30-day free trial). Sign up and get a valid account on Gluon CloudLink and a link to access the Gluon Dashboard.

Dashboard

Open Gluon Dashboard in your browser, and sign in using your Gluon account credentials (the ones that you provided when creating the account).

Sign in

Creating the remote functions

The objective of the application are as follows:

  • Use StackExchange API to retrieve a list of questions based on tags

  • For each user selected question, show a list of answers using the API

All the communication to the API will be via remote functions. These remote functions will be created in Gluon Dashboard.

Go to the API Management link, select the functions tab, and click on the + button, to insert a new remote function.

query tagged - javafx

variable questionID - 1000000

API Management view

searchStackOverflow

Our first remote function, named searchStackOverflow, will make use of the search API to look for questions based on a given tag.

The remote function requires a name: searchStackOverflow; the request method will be GET and the endpoint will be:

search function

Click save to save and see the function information:

function information

Now we need to add a query parameter to the request, by clicking the function parameters + button.

adding function parameter

By default, a form parameter is defined. We enter edit mode by double clicking on it and select query param from the list.

adding function parameter type

As defined by the StackExchange API, we will add tagged as the query parameter name. It will return a list of questions that include this tag. Note that we don’t need to add the parameter to the endpoint string, it is added automatically.

We don’t need to specify a value for it, as it will be defined by the client application later, based on one of the many tags available at StackOverflow.

adding function parameter name

We can press enter or click on Save to commit the changes.

defined function

Finally, we can test the remote function from the dashboard. We double click on the cell under the Test value column header, to add a value to the tagged parameter, like javafx, and press enter or click Save.

adding test value

Now we click the Test button. You should see the response, with a 200 on top (HTTP status code OK), meaning that the endpoint is working. Otherwise, review its URL.

test the function

answersStackOverflow

For the second function, we query for all answers of a user selected question. We name the remote function as answersStackOverflow. The endpoint will be based on the answers on questions API: https://api.stackexchange.com/2.2/questions/$questionID/answers?order=desc&sort=activity&site=stackoverflow.

answers function

Note that we have included $questionID as part of the URI, so we need to add a variable parameter for it, named questionID. The value will be defined in the request from the client app.

answers function parameter

We can test the function by adding a test value to the table (i.e. 1000000) and clicking the Test button.

test the function

Finally, you can visualize the list of remote functions you have defined in your project from the drop down list, editing, removing or testing them at any time.

list of functions

Now that we have the functions ready, we will create the client app.

Creating the project

We will base our project on the sample project created by the FXML based JavaFX Maven Archetype.

In IntelliJ IDEA, click File → Project…​ and select Maven on the left. Check Create from archetype checkbox.

If the archetype is not present, you can click on Add Archetype…​ button

Archetype

Select org.openjfx:javafx-archetype-fxml from the list of archetypes and press Next.

Select Archetype

Provide a proper groupId, artifactId and version to the application.

New Project

Press Next. Check all the details and press Next again. Change the project name, if required. Press Finish and the project will be created and opened.

Editing the project

Once the project is opened in IntelliJ IDEA, we can start editing the project.

In order to connect the project with the Gluon CloudLink, go to the Dashboard, Credentials link, Client tab, and you will find a pair of key/secret tokens. Click on the download button to download the file gluoncloudlink_config.json, and then store it under your project src/main/resources/ folder.

Configuration File

The content of the file is a JSON object with the key and secret that will grant access to Gluon CloudLink:

src/main/resources/gluoncloudlink_config.json
{
  "gluonCredentials": {
    "applicationKey" : "f88XXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    "applicationSecret": "7fbXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
  }
}

The model

Given that we’ll be dealing with StackOverflow JSON responses, our model should have some of the fields in those responses.

{
  "items": [
    {
      "owner": {
        "reputation": 126,
        "user_id": 77153,
        "user_type": "registered",
        "accept_rate": 29,
        "profile_image": "https://www.gravatar.com/avatar/006e73b99474923474943609ed35037b?s=128&d=identicon&r=PG",
        "display_name": "Jack Njiri",
        "link": "http://stackoverflow.com/users/77153/jack-njiri"
      },
      "is_accepted": false,
      "score": 0,
      "last_activity_date": 1245242062,
      "creation_date": 1245242062,
      "answer_id": 1006730,
      "question_id": 1000000
    },
    {
      "owner": {
        "reputation": 30025,
        "user_id": 22595,
        "user_type": "registered",
        "accept_rate": 81,
        "profile_image": "https://www.gravatar.com/avatar/32fef6b6cf057874c189a9ff40029573?s=128&d=identicon&r=PG",
        "display_name": "Michał Niklas",
        "link": "http://stackoverflow.com/users/22595/micha%c5%82-niklas"
      },
      "is_accepted": false,
      "score": 2,
      "last_activity_date": 1245146157,
      "creation_date": 1245146157,
      "answer_id": 1000537,
      "question_id": 1000000
    }
  ],
  "has_more": false,
  "quota_max": 10000,
  "quota_remaining": 9995
}

We create a StackOwner class with just three fields:

StackOwner.java
public class StackOwner {
    private String display_name;
    private String profile_image;
    private int reputation;

    // getters & setters, toString
}

and a StackEntry class that will be valid for both questions and answers:

StackEntry.java
public class StackEntry {
    private int answer_count;
    private long creation_date;
    private boolean is_accepted;
    private String link;
    private StackOwner owner;
    private long question_id;
    private int score;
    private String title;

    // getters & setters, toString
}

Finally, the items array that will define the response will require the StackResponse class:

StackResponse.java
public class StackResponse {
    private List<StackEntry> items = new LinkedList();

    // getters & setters, toString
}

Note that you can add more fields to the model, if required.

The RemoteService

We add a new class to deal with the remote functions requests. For each remote function, we’ll create a RemoteFunction, based on their name defined in Gluon CloudLink, and the param name and its value:

RemoteFunctionObject function = RemoteFunctionBuilder
                .create("searchStackOverflow")
                .param("tagged", value)
                .object();

RemoteFunctionObject#call uses a DataProvider to retrieve data and returns a GluonObservableObject<StackResponse>.

This is all it takes to generate an observable object from a remote endpoint, based only in a given value passed to the query.

RemoteService.java
public class RemoteService {

    public GluonObservableObject<StackResponse> answersStackOverflow(String value) {
        RemoteFunctionObject function = RemoteFunctionBuilder
                .create("answersStackOverflow")
                .param("questionID", value)
                .object();
        return function.call(StackResponse.class);
    }

    public GluonObservableObject<StackResponse> searchStackOverflow(String value) {
        RemoteFunctionObject function = RemoteFunctionBuilder
                .create("searchStackOverflow")
                .param("tagged", value)
                .object();
        return function.call(StackResponse.class);
    }
}

Testing the service

At this point we can already test the remote service. For that, we add two requests in the PrimaryController, replacing the pre-generated code in the switchToSecondary method, with this one:

PrimaryController.java
    @FXML
    private void switchToSecondary()
        System.out.println("searchStackOverflow");
        GluonObservableObject<StackResponse> searchStackOverflow = remoteService.searchStackOverflow(StackResponse.class, "gluon");
        searchStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                System.out.println(searchStackOverflow.get());
            }
        });

        System.out.println("\nanswersStackOverflow");
        GluonObservableObject<StackResponse> answersStackOverflow = remoteService.answersStackOverflow(StackResponse.class, "1000000");
        answersStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                System.out.println(answersStackOverflow.get());
            }
        });
    }

Run mvn javafx:run and click on "Switch to Secondary View" to get a response like below:

searchStackOverflow
StackResponse{items=[StackEntry{answer_count=0, creation_date=1490624501, is_accepted=false, link=http://stackoverflow.com/questions/43049006/scenebuilder-8-3-0-disappearing-menus-and-no-resize, owner=StackOwner{display_name=JeanPhi, profile_image=https://www.gravatar.com/avatar/add1641f18223ddfd443404332ecc49f?s=128&d=identicon&r=PG&f=1, reputation=1}, question_id=43049006, score=0, title=scenebuilder 8.3.0 disappearing menus and no resize},
... }

answersStackOverflow
StackResponse{items=[StackEntry{answer_count=0, creation_date=1245242062, is_accepted=false, link=null, owner=StackOwner{display_name=Jack Njiri, profile_image=https://www.gravatar.com/avatar/006e73b99474923474943609ed35037b?s=128&d=identicon&r=PG, reputation=126}, question_id=1000000, score=0, title=null}, StackEntry{answer_count=0, creation_date=1245146157, is_accepted=false, link=null, owner=StackOwner{display_name=Michał Niklas, profile_image=https://www.gravatar.com/avatar/32fef6b6cf057874c189a9ff40029573?s=128&d=identicon&r=PG, reputation=30025}, question_id=1000000, score=2, title=null}]}

Adding custom views

We delete the pre-generated code and add custom fxml files along with their controllers.

Create a new main.fxml with Scene Builder, add a ListView to show the list of entries and an editable ComboBox to add a list of valid tags.

Main view

In the MainController, add the custom cells for the listView and set the service call on new tag selection:

MainController.java
public class MainController extends AbstractController {

    private static final List<String> TAGS = List.of("gluon", "gluon-mobile", "javafxports", "javafx", "scenebuilder");

    @FXML
    private BorderPane main;

    @FXML
    private HBox top;

    @FXML
    private ComboBox<String> tagComboBox;

    @FXML
    private ListView<StackEntry> listView;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {

        tagComboBox.getItems().addAll(TAGS);

        listView.setPlaceholder(new Label("No items yet\nSelect a tag"));

        listView.setCellFactory(p -> new ListCell<>() {

            private final ListTile tile;
            private final ImageView imageView;

            {
                tile = new ListTile();
                imageView = new ImageView();
                tile.setPrimaryGraphic(imageView);
            }

            @Override
            public void updateItem(StackEntry item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    imageView.setImage(Util.getImage(item.getOwner().getProfile_image()));
                    tile.textProperty().setAll(item.getTitle(),
                            item.getOwner().getDisplay_name(),
                            "Created: " + Util.FORMATTER.format(LocalDateTime.ofEpochSecond(item.getCreation_date(), 0, ZoneOffset.UTC)) +
                                    " - Answers: " + item.getAnswer_count());
                    tile.setOnMouseClicked(e -> {
                        DetailController detailController =
                                (DetailController) getApp().getController("detail");
                        detailController.setStackEntry(item);
                        getApp().setRoot(detailController.getRoot());
                    });
                    setGraphic(tile);
                } else {
                    setGraphic(null);
                }
            }

        });

        tagComboBox.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> search(nv));
    }

    @Override
    public Parent getRoot() {
        return main;
    }

    private void search(String tag) {
        reset();
        GluonObservableObject<StackResponse> searchStackOverflow = getApp().service().searchStackOverflow(StackResponse.class, tag);
        searchStackOverflow.setOnSucceeded(e -> {
            listView.setItems(FXCollections.observableArrayList(searchStackOverflow.get().getItems()));
            if (top.getChildren().size() == 3) {
                top.getChildren().remove(2);
            }
        });
        searchStackOverflow.setOnFailed(e -> {
            if (top.getChildren().size() == 3) {
                top.getChildren().set(2, new Label("Please try again"));
            }
        });
    }

    private void reset() {
        if (top.getChildren().size() == 2) {
            top.getChildren().add(new ProgressIndicator());
        } else if  (top.getChildren().get(2) instanceof Label) {
            top.getChildren().set(2, new ProgressIndicator());
        }
        listView.setItems(FXCollections.emptyObservableList());
    }
}

The AbstractController define common methods used by both the controllers.

AbstractController.java
public abstract class AbstractController implements Initializable {

    private FunctionMapper functionMapper;

    public void setApp(FunctionMapper functionMapper) {
        this.functionMapper = functionMapper;
    }

    public FunctionMapper getApp() {
        return functionMapper;
    }

    public abstract Parent getRoot();
}

Util class contains a proper DateTimeFormatter and the image cache to avoid downloading the images over and over again when scrolling the listView. We add the Cache plugin to the build script using the Gluon Mobile Settings option.

Util.java
public class Util {

    public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd/MMM/yyyy");

    private static final Cache<String, Image> CACHE = Services.get(CacheService.class)
            .map(cache -> cache.<String, Image>getCache("images"))
            .orElseThrow(() -> new RuntimeException("No cache service"));


    public static Image getImage(String imageName) {
        if ((imageName == null) || (imageName.isEmpty())) {
            return null;
        }

        Image image = CACHE.get(imageName);
        if (image == null) {
            image = new Image(imageName, 36.0, 36.0, true, true, true);
            CACHE.put(imageName, image);
        }
        return image;
    }

}

Let’s run the app now to check that everything is in place.

Testing the main view

Enhancing the detail view

Create a detail.fxml with Scene Builder. Add a section to display the question with a ListView below it to show the list of answers. Also, add a button at the top to switch to the Main view.

Details view

In the DetailController, add the custom cells for the listView and set the service call based on the id of the question.

DetailController.java
public class DetailController extends AbstractController {

    @FXML
    private BorderPane detail;

    @FXML
    private VBox question;

    @FXML
    private ListView<StackEntry> listView;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        listView.setPlaceholder(new Label("No answers yet"));
        listView.setCellFactory(p -> new ListCell<>() {

            private final ListTile tile;
            private final ImageView imageView;

            {
                tile = new ListTile();
                imageView = new ImageView();
                tile.setPrimaryGraphic(imageView);
            }

            @Override
            public void updateItem(StackEntry item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    imageView.setImage(Util.getImage(item.getOwner().getProfile_image()));
                    tile.textProperty().setAll(item.getOwner().getDisplay_name(),
                            "Reputation: " + item.getOwner().getReputation(),
                            "Answered: " + Util.FORMATTER.format(LocalDateTime.ofEpochSecond(item.getCreation_date(), 0, ZoneOffset.UTC)));
                    setGraphic(tile);
                } else {
                    setGraphic(null);
                }
            }

        });
    }

    @Override
    public Parent getRoot() {
        return detail;
    }

    public void backToMain(ActionEvent event) {
        AbstractController mainController = getApp().getController("main");
        getApp().setRoot(mainController.getRoot());
    }

    public void setStackEntry(StackEntry stackEntry) {
        StackOwner stackOwner = stackEntry.getOwner();
        if (stackOwner != null) {
            createListTile(stackEntry, stackOwner);
        }
        search(String.valueOf(stackEntry.getQuestion_id()));
      }

    private void createListTile(StackEntry stackEntry, StackOwner stackOwner) {
        ImageView imageView = new ImageView(Util.getImage(stackOwner.getProfile_image()));
        imageView.setPreserveRatio(true);
        ListTile listTile = new ListTile();
        listTile.setPrimaryGraphic(imageView);
        listTile.textProperty().setAll(stackEntry.getTitle(),
                stackOwner.getDisplay_name(),
                "Created: " + Util.FORMATTER.format(LocalDateTime.ofEpochSecond(stackEntry.getCreation_date(), 0, ZoneOffset.UTC)) +
                    " - Answers: " + stackEntry.getAnswer_count());
        Button open = new Button("Open");
        open.setOnAction(e -> getApp().getHostServices().showDocument(stackEntry.getLink()));
        listTile.setSecondaryGraphic(open);
        if (question.getChildren().size() == 2) {
            question.getChildren().set(1, listTile);
        } else {
            question.getChildren().add(listTile);
        }
    }

    private void search(String questionId) {
        listView.setItems(FXCollections.emptyObservableList());
        GluonObservableObject<StackResponse> answersStackOverflow = getApp().service().answersStackOverflow(StackResponse.class, questionId);
        answersStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                listView.setItems(FXCollections.observableArrayList(answersStackOverflow.get().getItems()));
            }
        });
    }
}

Note that we have to pass the question id value from the main view. When the user clicks on a given cell, the view is switched to the Detail view, and setStackEntry is called:

MainController.java
    @Override
    public void updateItem(StackEntry item, boolean empty) {
        super.updateItem(item, empty);
        if (item != null && !empty) {
            ...
            tile.setOnMouseClicked(e -> {
                DetailController detailController =
                        (DetailController) getApp().getController("detail");
                detailController.setStackEntry(item);
                getApp().setRoot(detailController.getRoot());
            });

            setGraphic(tile);
        } else {
            setGraphic(null);
        }
    }

Finally, we need to open the question in an external browser when the "Open" button is clicked:

DetailController.java
    public void setStackEntry(StackEntry stackEntry) {
        StackOwner stackOwner = stackEntry.getOwner();
        if (stackOwner != null) {
            ...
            Button open = new Button("Open");
            open.setOnAction(e -> getApp().getHostServices().showDocument(stackEntry.getLink()));
            listTile.setSecondaryGraphic(icon);
            ...
        }
        search(String.valueOf(stackEntry.getQuestion_id()));
      }

Again, let’s run the application to test the latest changes.

Testing details view

Mocking responses

Sometimes the endpoints won’t be available yet or the real response won’t include certain values. But having a response or those values in it comes in handy when developing and testing the application. In these case, we can easily add mock data to the remote function.

TO add mock data to remote functions, edit one of the functions and check Enable Mock. In this case, select application/json format from the list and add some mock data:

{"items":[{"tags":["gluon","gluon-mobile"],"owner":{"reputation":100000,"profile_image":"http://gluonhq.com/wp-content/uploads/2015/01/363970-brand-energy-fas@2x.png","display_name":"gluon"},"is_answered":false,"answer_count":10,"score":100,"creation_date":1490340785,"question_id":1000000,"link":"http://gluonhq.com","title":"Mock Data"}]}
Adding mock data

Now, we can test the function. No matter what tag the user selects, the response will always be the mock data.

Testing mock data

You can disable this feature at any time by un-checking the Enable Mock checkbox.

Conclusion

During this tutorial we have accomplished several tasks:

  • We have used Gluon Dashboard to create remote functions that process REST requests from a given endpoint and return JavaFX observable objects.

  • We have created a client project using JavaFX 11+ that can easily run on Desktop using the JavaFX Maven Plugin. The application connects to a remote service using Gluon Cloudlink functions, and the required configuration file.

  • We have modified the views to include a ListView control that will render the response from Gluon CloudLink.

  • We have added mock data in case the real response is not available but we need to do some testing.

One of the main advantages from the point of view of a front-end developer is the complete decoupling from the back-end side implementation: no HTTP protocols, no REST services, no JSON parsing, no URL dependencies…​ The client side just gets observable data that can be easily rendered using the controls provided by the JavaFX library, allowing fast development and short time to market. It also allows us to make changes in the back-end without the need of redeployment of the application.

If you have made it this far, congratulations! We hope this sample and the documentation was helpful to you. In case you have any questions, your first stop should be the Gluon support page, where you can access Gluon CloudLink latest documentation. Gluon also recommends the community to support each other at the Gluon StackOverflow page. Finally, Gluon offers commercial support as well, to kick start your projects.