Using ADS from Web Applications and React
This section explains how the APIs of Active Data Shapes (ADS) can be used in client-side code of Web Applications. While ADS scripts typically execute on the TopBraid server (using the GraalVM engine), TopBraid can also generate stand-alone JavaScript/TypeScript files of the ADS APIs, including features like ontology-specific JavaScript classes and the generic graph object. These libraries can then be used by JavaScript apps running in the browser, for example embedded in the React source code. Such apps can utilize the full flexibility of ADS which goes way beyond what could be expressed using SPARQL or GraphQL. The functions containing the queries and mutations are sent from the web client to the TopBraid server for execution. All this is enabled because both the TopBraid server and the Web applications can share the same APIs to declare ADS scripts.
Overview
An important design question for interactive web applications is how to best communicate with the server. Modern web applications make “Ajax” requests based on languages like GraphQL to fetch JSON responses from the server. Against the TopBraid server, many requests also use SPARQL. Common to all these is that the client code needs to construct query strings (e.g., in GraphQL) based on the current state of the user interface, make the asynchronous call, and finally process the (JSON) result into the format that is really needed to update the user interface component.
This section introduces a different way for web applications to interact with the TopBraid server.
Instead of sending query strings, the client can send arbitrary JavaScript functions to the server.
The server will use its embedded GraalVM engine to execute those functions and then return the results to the client.
The API behind those functions is automatically created from the data models/ontologies using the Active Data Shapes API generator.
So for example you can write code such as skos.everyConcept()
if you need an array of all instances of skos:Concept
from the current asset collection.
This is typically a very convenient and powerful API to query or update TopBraid graphs.
One “business benefit” of this design is that client-side code can contain functions that would otherwise have to be defined on the server and thus would need to live in separate parts of a system under different maintenance procedures using different tools. With ADS, web application code can benefit from compile-time checking, a rich editing experience using auto-complete etc. Furthermore, the client code can directly produce the exact JavaScript data structures that are needed by the component, without first having to go through the generic JSON formats produced by GraphQL or SPARQL.
The following diagram illustrates the overall architecture of Active Data Shapes, when executed on the TopBraid EDG server versus from Web Browsers versus from Node.js.
All this is best explained using examples, so let’s dive straight in.
Simple Example
This example simply prints the number of triples in the current asset collection. The code is divided into two files:
A JavaScript file containing the ADS code with a function that implements the query logic
A React component for the rendering, importing the ADS file
The ADS File
The file HelloComponent.ads.js
declares the query function(s) that will be executed on the server.
It also exports a dedicated function that serves as proxy for the remote function call.
The code of that proxy function is basically always the same, calling a function TopBraid.asyncFunction
, and mirrors the parameter passing of the remote function:
1//@ts-check
2
3import { graph, TopBraid } from './geography_ontology_ADS_generated_web';
4
5/**
6 * This function executes on the server to count the total number of triples.
7 * @returns {number} the number of triples
8 */
9const triplesCount = () => {
10 // This here could be literally any ADS algorithm...
11 return graph.triples(null, null, null).length;
12}
13
14/**
15 * This executes on the client and serves as proxy for the server-side function above.
16 * @returns {Promise<number>} the number of triples, but wrapped into a Promise
17 */
18export const loadTriplesCount = () => {
19 return TopBraid.asyncFunction(triplesCount);
20}
The React Component
The file HelloComponent.jsx
defines the rendering only, and delegates the data loading to the ADS file:
1import React from 'react';
2
3import { loadTriplesCount } from './HelloComponent.ads';
4
5/**
6 * A test component that displays the number of triples in the current data graph.
7 */
8export default class HelloComponent extends React.Component {
9
10 async componentDidMount() {
11 this.setState({
12 count: await loadTriplesCount()
13 })
14 }
15
16 render() {
17 if(!this.state) {
18 return <div>Loading...</div>;
19 }
20 return (
21 <div>
22 <h3>Triples: {this.state.count}</h3>
23 </div>
24 );
25 }
26}
The JavaScript module geography_ontology_ADS_generated_web.js
has been automatically generated by TopBraid.
To produce such a file, use the Export tab of your Ontology (here, the Geography Ontology) and select Generate API for External Scripts > JavaScript API for Web Applications.
Alternatively, you may want to rely on one of the standard ADS libraries, for example for SKOS, that can be generated in the same Export feature.
The .ads.js
file defines all helper functions of the component that may execute on the server.
These functions must only use the generated ADS APIs, because that very same API will also exist on the TopBraid server at execution time.
The client code base has a copy of that API, but that is only used for compilation and syntax checking, while the execution happens against the identical ADS API on the server.
Given that the source code of the JavaScript functions is sent to the server, it will execute in a different environment from the client. These functions may therefore not call the React API or other client-side features, nor can they access variables from surrounding scopes on the client, unless they are passed into the function as parameters. In other words, the script functions need to be stateless, self-contained and rely on ECMAScript only. See Variables and Scope for details.
In the example above, only the triplesCount
function will be sent to the server for execution.
The function loadTriplesCount
serves as a wrapper or proxy to expose this function to the client code from the React source file.
TopBraid.asyncFunction
returns a JavaScript Promise
object, i.e. it will start an asynchronous call and return instantly.
The await
keyword may be used to process the result of the Promise
in the most readable syntax.
As shown in the HelloComponent source code, the function surrounding the await must be declared async
.
Alternatively, use the conventional Promise
syntax such as loadTriplesCount().then((data) => ... )
.
Use TopBraid.asyncFunction
for any read-only operation, and TopBraid.asyncMutation
for operations that may also modify the graph.
Complex Example
For this example we want to render a skos:ConceptScheme
together with a list of its top concepts and the number of their children each.
Embedded into an EDG Panel it will look as shown:
The ADS File
The query logic here requires a helper function that recursively walks down the hierarchy of narrower concepts.
Furthermore, this example is complicated by the fact that we want to pass an argument from the client (the selected concept scheme) into the ADS code, and the ADS code represents this concept scheme as instance of the JavaScript class skos_ConceptScheme
while the client simple uses an instance of RDFValue
or any other object that has a uri field.
This complicates the invocation a little bit, because the URI needs to be cast into the correct ADS API class.
The file is also quite bloated because it has been littered with JSDocs comments that help with type-safety and documentation. This can of course be achieved in more compact form in TypeScript, or simply removed.
1//@ts-check
2
3import { skos_Concept, skos_ConceptScheme, TopBraid } from './geography_ontology_ADS_generated_web';
4
5import * as RDFValue from '../../model/RDFValue';
6
7/**
8 * This function executes on the server to recursively sum up the number of children.
9 * @param {skos_Concept} concept - the skos:Concept to count the children of
10 * @returns {number}
11 */
12const countChildren = (concept) => {
13 let count = 0;
14 concept.narrower.forEach(child => {
15 count += countChildren(child) + 1
16 })
17 return count;
18}
19
20/**
21 * This function executes on the server to collect the info objects for the top concepts.
22 * @param {skos_ConceptScheme} scheme - the skos:ConceptScheme instance
23 * @returns {Object[]} an array of { resource: skos_Concept, childCount: number } objects
24 */
25const getChildInfo = (scheme) => {
26 return scheme.hasTopConcept.map(topConcept => ({
27 resource: topConcept,
28 childCount: countChildren(topConcept),
29 }))
30}
31
32/**
33 * @typedef {Object} ChildInfo
34 * @property {Object} resource - the resource, with { uri: string, label: string }
35 * @property {number} childCount - the total number of children
36 */
37
38/**
39 * Fetches the info objects for the top concepts of a given concept scheme from the server.
40 * Note that the argument type (RDFValue) is different from what getChildInfo expects, so
41 * these will be typecast on the fly.
42 * @function
43 * @param {RDFValue} scheme - the skos:ConceptScheme instance
44 * @returns {Promise<ChildInfo[]>} an array of { resource: RDFValue, childCount: number } objects
45 */
46export const loadChildInfo = (scheme) => {
47 return TopBraid.asyncFunction(getChildInfo, [scheme], [skos_ConceptScheme], [countChildren]);
48}
Note the additional arguments of the TopBraid.asyncFunction
call:
[scheme]
is the list of parameter values, as an array in the same order as expected bygetChildInfo
[skos_ConceptScheme]
tells the engine that it needs to type-cast the first argument scheme into an instance of the ADS classskos_ConceptScheme
[countChildren]
is an array of any helper functions that also need to be sent to the server for the executionThe template for those load functions is however always the same and quite trivial to derive with a bit of practice. What matters is that the actual business logic can be written down as part of the component’s JavaScript source code, and not as some string.
The React Component
The file TestComponent.jsx
defines the rendering only, and delegates the data loading to the ADS file:
1import React from 'react';
2
3import { loadChildInfo } from './TestComponent.ads';
4
5/**
6 * A component that displays info about the top concepts of a provided concept scheme
7 * (props.scheme)
8 */
9export default class TestComponent extends React.Component {
10
11 constructor(props) {
12 super(props);
13 this.state = {
14 items: []
15 };
16 }
17
18 componentDidMount() {
19 if(this.props.scheme) {
20 this.loadData();
21 }
22 }
23
24 componentDidUpdate(oldProps) {
25 if(oldProps.scheme != this.props.scheme) {
26 this.loadData();
27 }
28 }
29
30 async loadData() {
31 this.setState({
32 items: await loadChildInfo(this.props.scheme)
33 });
34 }
35
36 render() {
37 if(!this.props.scheme) {
38 return <div>Please select a Concept Scheme</div>;
39 }
40 return (
41 <div className="TestComponent" style={{padding: '8px'}}>
42 <h1>Top Concepts of {this.props.scheme.label}</h1>
43 <ul>
44 {this.state.items.map(item => (
45 <li>{item.resource.label} has {item.childCount} narrower concepts</li>
46 ))}
47 </ul>
48 </div>
49 );
50 }
51}
Variables and Scope
When JavaScript functions are sent to the server for execution using TopBraid.asyncFunction
they operate in a very different environment from the (Web) client.
In particular the scoping of variables is different and requires attention to detail.
As a general rule, any function that gets sent to the server can only process its direct arguments and can not rely on any variables outside of the scope.
So if your web component keeps its own data structure and you want the ADS function to use that structure, you need to explicitly pass this data to the function.
Let’s look at an example in which the client has a state object such as
1let data = {
2 name: 'John Doe',
3 address: {
4 street: 'Teewah Close',
5 postalCode: 4879,
6 }
7};
8let result = await loadSomething(data);
9// result.name is now 'Jane Doe' and data remains unchanged
And loadSomething
is a proxy function from an .ads.js
file:
1import { TopBraid } from './geography_ontology_ADS_generated_web';
2
3const something = (data) => {
4 data.name = 'Jane Doe';
5 return data;
6}
7
8export const loadSomething = (data) => {
9 return TopBraid.asyncFunction(something, [data]);
10}
At execution time, the something
function will run within the server sandbox environment.
There, the data argument will be a deep copy of the original data object from the client.
It will be passed into the function through generated JavaScript code using its JSON serialization.
(This means that any such argument must be serializable into self-contained JSON - make sure it does not contain cycles and also make sure you don’t pass huge objects over the wire).
In this case, the something function may modify its own local copy of the data object, yet the client’s copy will of course remain unchanged.
Later, when the data object gets returned from the server-side function, the client will again receive a new clone and TopBraid uses serialization via JSON to exchange the data between client and server.
As a result, modifications to the object on the server will not affect the client-side object.
Since we use JSON serialization to transport objects between client and server, extra care needs to be taken if the JavaScript objects are typed as LiteralNode
or NamedNode
or a subclass of that.
By default, the type/class information will get lost.
However, you can use the third argument of TopBraid.asyncFunction
to specify the type(s) that each argument should have on the server.
This approach had been illustrated in the Complex Example, where the server-side function expects the argument to be an instance of the JavaScript class skos_ConceptScheme
.
For this mechanism to work, the argument values simply need to be objects with a uri
field.
In cases where the expected type is LiteralNode
, the values may be simple JavaScript values of type boolean, number or string, or be objects with a lex
field and an optional lang
field and optional datatype
(full datatype URI) or dt
(local name of the datatype).
This is the same format that is used by ADS literal nodes, but should also work for the literal representation within the TopBraid EDG user interface code.
If the client-side code expects the result object of the async call to be of a certain JavaScript class, it needs to perform the appropriate type casting itself.
The async
function will only ever return plain JavaScript values or objects that fit into JSON serialization.