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).
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.
Open Gluon Dashboard in your browser, and sign in using your Gluon account credentials (the ones that you provided when creating the account).
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
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:
Click save
to save and see the function information:
Now we need to add a query parameter to the request, by clicking the function parameters +
button.
By default, a form
parameter is defined. We enter edit mode by double clicking on it and select query param
from the list.
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.
We can press enter or click on Save
to commit the changes.
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
.
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.
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.
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.
We can test the function by adding a test value to the table (i.e. 1000000) and clicking the Test button.
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.
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
Select org.openjfx:javafx-archetype-fxml
from the list of archetypes and press Next
.
Provide a proper groupId
, artifactId
and version
to the application.
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.
The content of the file is a JSON object with the key and secret that will grant access to Gluon CloudLink:
{
"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.
For instance, for this request https://api.stackexchange.com/2.2/questions/1000000/answers?order=desc&sort=activity&site=stackoverflow, we have this response:
{
"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:
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:
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:
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.
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:
@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.
In the MainController
, add the custom cells for the listView and set the service call on new tag selection:
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.
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.
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.
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.
In the DetailController
, add the custom cells for the listView and set the service call based on the id of the question.
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:
@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:
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.
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"}]}
Now, we can test the function. No matter what tag the user selects, the response will always be the 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.