This document 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 7.1 introduces a feature to 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.

Scope of This Document

This document is part of the Active Data Shapes framework. We assume that you are familiar with the Active DASH Tutorial. The approach described here has similarities with the Using Active Data Shapes on Node.js document.

This feature requires TopBraid 7.1 which is currently scheduled for a September release date.

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 document introduces a new 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:

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:

//@ts-check

import { graph, TopBraid } from './geography_ontology_ADS_generated_web';

/**
 * This function executes on the server to count the total number of triples.
 * @returns {number} the number of triples
 */
const triplesCount = () => {
    // This here could be literally any ADS algorithm...
    return graph.triples(null, null, null).length;
}

/**
 * This executes on the client and serves as proxy for the server-side function above.
 * @returns {Promise<number>} the number of triples, but wrapped into a Promise
 */
export const loadTriplesCount = () => {
    return TopBraid.asyncFunction(triplesCount);
}

The React Component

The file HelloComponent.jsx defines the rendering only, and delegates the data loading to the ADS file:

import React from 'react';

import { loadTriplesCount } from './HelloComponent.ads';

/**
 * A test component that displays the number of triples in the current data graph.
 */
export default class HelloComponent extends React.Component {

    async componentDidMount() {
        this.setState({
            count: await loadTriplesCount()
        })
    }

    render() {
        if(!this.state) {
            return <div>Loading...</div>;
        }
        return (
            <div>
                <h3>Triples: {this.state.count}</h3>
            </div>
        );
    }
}

How does this work?

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 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.

//@ts-check

import { skos_Concept, skos_ConceptScheme, TopBraid } from './geography_ontology_ADS_generated_web';

import * as RDFValue from '../../model/RDFValue';

/**
 * This function executes on the server to recursively sum up the number of children.
 * @param {skos_Concept} concept - the skos:Concept to count the children of
 * @returns {number}
 */
const countChildren = (concept) => {
    let count = 0;
    concept.narrower.forEach(child => {
        count += countChildren(child) + 1
    })
    return count;
}

/**
 * This function executes on the server to collect the info objects for the top concepts.
 * @param {skos_ConceptScheme} scheme - the skos:ConceptScheme instance
 * @returns {Object[]} an array of { resource: skos_Concept, childCount: number } objects
 */
const getChildInfo = (scheme) => {
    return scheme.hasTopConcept.map(topConcept => ({
        resource: topConcept,
        childCount: countChildren(topConcept),
    }))
}

/**
 * @typedef {Object} ChildInfo
 * @property {Object} resource - the resource, with { uri: string, label: string }
 * @property {number} childCount - the total number of children
 */

/**
 * Fetches the info objects for the top concepts of a given concept scheme from the server.
 * Note that the argument type (RDFValue) is different from what getChildInfo expects, so
 * these will be typecast on the fly.
 * @function
 * @param {RDFValue} scheme - the skos:ConceptScheme instance
 * @returns {Promise<ChildInfo[]>} an array of { resource: RDFValue, childCount: number } objects
 */
export const loadChildInfo = (scheme) => {
    return TopBraid.asyncFunction(getChildInfo, [scheme], [skos_ConceptScheme], [countChildren]);
}

Note the additional arguments of the TopBraid.asyncFunction call:

The 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:

import React from 'react';

import { loadChildInfo } from './TestComponent.ads';

/**
 * A component that displays info about the top concepts of a provided concept scheme
 * (props.scheme)
 */
export default class TestComponent extends React.Component {

    constructor(props) {
        super(props);
        this.state = {
            items: []
        };
    }

    componentDidMount() {
        if(this.props.scheme) {
            this.loadData();
        }
    }

    componentDidUpdate(oldProps) {
        if(oldProps.scheme != this.props.scheme) {
            this.loadData();
        }
    }

    async loadData() {
        this.setState({
        	items: await loadChildInfo(this.props.scheme)
        });
    }

    render() {
        if(!this.props.scheme) {
            return <div>Please select a Concept Scheme</div>;
        }
        return (
            <div className="TestComponent" style={{padding: '8px'}}>
                <h1>Top Concepts of {this.props.scheme.label}</h1>
                <ul>
                    {this.state.items.map(item => (
                        <li>{item.resource.label} has {item.childCount} narrower concepts</li>
                    ))}
                </ul>
            </div>
        );
    }
}

Variables and Scopes

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

let data = {
    name: 'John Doe',
    address: {
        street: 'Teewah Close',
        postalCode: 4879,
    }
};
let result = await loadSomething(data);
// result.name is now 'Jane Doe' and data remains unchanged

And loadSomething is a proxy function from an .ads.js file:

import { TopBraid } from './geography_ontology_ADS_generated_web';

const something = (data) => {
	data.name = 'Jane Doe';
	return data;
}

export const loadSomething = (data) => {
    return TopBraid.asyncFunction(something, [data]);
}

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 , 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.