The correct way to resolve a link to a resource

The aim of this document is to give developers information how to correctly resolve links provided by CTMS. Mind that CTMS is based on the idea of HATEOAS were we try to get along with only following links. In "traditional" (RPC-style) REST APIs code usually tries creating URLs of hard-coded segments and input data. However that URLs are assumed to contain specific segments or adhere to a specifc structure is risky. This assumption will lead to breaking code if the URL-structure of the service has changed underneath. To avoid this kind of problem, CTMS understands URLs offered by links as loose pointers, which can change when the service underneath evolves.

In CTMS instead of relying on hard-coded entrypoints to services we rely on a central place were the entrypoints to the CTMS-services (CTCs) can be found. This central place is the CTMS Registry.

A CTC’s Perspective

From a CTC-standpoint the CTMS Registry is the central place to which a CTC provides its service-root resource that contains the entrypoints to the CTC.

A CTMS Client’s Perspective

From a CTMS client’s perspective the CTMS Registry is the central place where to find a specific resource’s URL. But this is not yet everything a CTMS client needs to be aware of. The URL the clients gets from the CTMS Registry is not yet complete, in most cases it is a URL template that needs to be filled in by the CTMS client. A CTMS client needs following data/"coordinates" beforehand to find a specific resource and construct a URL:

  • the link-rel, this is the name of the operation provided by CTCs

  • the systemId, this is the id of a system to more precisely select a specific CTC

  • the systemType, this is the type of a system to more precisely select a specific CTC

  • the parameter names of the URL template and the arguments to be filled in

Besides that the CTMS-clients needs to know the endpoint of the CTMS Registry.

Important
The only endpoint a CTMS client needs to know is the endpoint of the CTMS Registry in form of a URL.

A CTMS Client’s general "Find Resource in CTMS Registry" Algorithm

This flow chart shows how to process the respose of the CTMS Registry in detail:

resolve_link_ctms_registry_flow
Warning
Following code snippets are not created for productive code. They do not check all error conditions and may get incompatible in details.

"Find Resource in CTMS Registry" in Java

The following code snippets cast the general algorithm into Java code using a HTTP client (Unirest in this case), types to do JSON-deserialization and a library to handle URL templates (damnhandy.uri.template in this case).

Note
To use CTMS as a client only three components are needed: an HTTP client, a JSON-parser and a library to handle URL templates.
/**
 * Performs a CTMS Registry lookup and returns the URL template for the resource (systemID,
 * systemType, linkRel) in question.
 *
 * @param apiDomain  host against to which we want to make the request
 * @param systemID   system in which we expect the resource to be present
 * @param systemType system type under which we expect the resource to be present
 * @param linkRel    resource to look up in the CTMS Registry, such as "search:simple-search"
 * @return an Optional<String> containing the URL template or Optional.empty() if the
 * CTMS Registry is unreachable or the resource in question cannot be found.
 */
public static Optional<String> findInRegistry(String apiDomain, String systemID
    , String systemType, String linkRel) {
try {
    final HttpResponse<String> response
            = Unirest
            .get("https://%s/ctms-registry/v1".formatted(apiDomain))
            .asString();

    final int serviceRootsStatus = response.getStatus();
    if (HttpURLConnection.HTTP_OK == serviceRootsStatus) {
        final String rawServiceRootsResult = response.getBody();
        final JSONObject serviceRootsResult = new JSONObject(rawServiceRootsResult);

        final JSONObject resources = serviceRootsResult.optJSONObject("resources");
        if (null != resources) {
            if (resources.has(linkRel)) {
                // 1. get all resources of the specified linkrel
                final Object resourcesObject = resources.get(linkRel);
                if (resourcesObject instanceof JSONArray resourcesAsArray) {
                    for (final Object resourceObject : resourcesAsArray) {
                        final JSONObject resource = (JSONObject) resourceObject;

                        final JSONArray systems = resource.getJSONArray("systems");
                        for (final Object systemObject : systems) {
                            final JSONObject system = (JSONObject) systemObject;
                            // 2. get the first resource that was registered with a system of the
                            // specified systemID and systemType
                            if (systemID.equals(system.optString("systemID"))
                                && systemType.equals(system.optString("systemType"))) {
                                // 3. get the href of the resource
                                return Optional
                                        .ofNullable(resource.opt("href"))
                                        .map(Object::toString);
                            }
                        }
                    }
                } else {
                    LOG.log(Level.INFO, "{0} not registered for system {1} and systemType {2}"
                        , new Object[]{linkRel, systemID, systemType});
                    return Optional.empty();
                }
            } else {
                LOG.log(Level.INFO, "{0} not registered for system {1} and systemType {2}"
                    , new Object[]{linkRel, systemID, systemType});
                return Optional.empty();
            }
        } else {
            LOG.log(Level.INFO, "{0} not registered for system {1} and systemType {2}"
                , new Object[]{linkRel, systemID, systemType});
            return Optional.empty();
        }
    } else {
        LOG.log(Level.INFO, "CTMS Registry not reachable (request failed)");
        return Optional.empty();
    }
} catch (final Throwable throwable) {
    LOG.log(Level.SEVERE, "failure", throwable);
}

LOG.log(Level.INFO, "unknown error requesting the CTMS Registry");
return Optional.empty();
}
Note
As can be seen, only the URL to the CTMS Registry is constructed explicitly. This example is using the new style REST endpoint of the CTMS Registry: "/ctms-registry/v1".

When findInRegistry() returns we either get an Optional containing a URL template or Optional.empty() denoting that the requested resource could not be found (or that there was an error).

Please notice that if succeeded, findInRegistry() returns a URL template, not yet a fully filled in URL. This will be done after findInRegistry() returns when we evaluate the result. The missing part of the algorithm is shown below. At this point the "coordinates" a CTMS client needs to find a resource and construct the URL come into play: link-rel, systemId, systemType and the parameter names ("id" in this case) of the URL template and the arguments to be filled in.

/// Just lookup a specific resource:
final String apiDomain = "myPlatform";
final String systemType = "interplay-mam";
final String linkRel = "aa:asset-by-id";
final String assetID = "myAssetId";
final URL assetURL
        = findInRegistry(apiDomain, systemID, systemType, linkRel)
        .map(UriTemplate::fromTemplate)
        .map(it -> {
            try {
                return new URL(it.set("id", assetID).expand());
            } catch (final MalformedURLException e) {
                LOG.info(e.getMessage());
                return null;
            }
        })
        .orElseThrow(() ->
            new IllegalStateException(
                "Can't resolve resource %s at system %s and systemType %s"
                .formatted(linkRel, systemID, systemType)));

LOG.log(Level.INFO, "URL: {0}", assetURL);

"Find in CTMS Registry" in JavaScript

For completeness following code snippets show JavaScript code doing the CTMS Registry lookup.

/**
 * Promises the URL template of the CTMS Registry lookup for the resource (systemID, systemType,
 * linkRel) in question.
 *
 * @method findInRegistry
 * @param {Object} options valid options for the next HTTP request against the platform
 * @param {String} apiDomain  host against to which we want to make the request
 * @param {String} systemID   system in which we expect the resource to be present
 * @param {String} systemType system type under which we expect the resource to be present
 * @param {String} linkRel    resource to look up in the CTMS Registry, such as
 *                            "search:simple-search"
 * @return {Promise} promising {"options": options, "href-data": {"href": {String}, templated:
 * {Boolean}}} containing valid options for the next HTTP request against the platform and the
 * found URL template.
 */
const findInRegistry = function (options, apiDomain, systemID, systemType, linkRel) {
    const deferred = new Promise((resolve, reject) => {

        /// Check, whether the CTMS Registry is available:
        options.path = "https://" + apiDomain + "/ctms-registry/v1";
        if (getCurrentAccessToken()) {
            options.headers.Cookie = 'avidAccessToken=' + getCurrentAccessToken();
        }
        https.get(options, onServiceRootsRequestResponded)
            .setTimeout(REQUEST_TIMEOUT, () => console.log("Request has timed out"));

        function onServiceRootsRequestResponded(serviceRootsResponse) {
            if (200 === serviceRootsResponse.statusCode) {
                const body = [];
                serviceRootsResponse.on('data', function onDataChunk(data) {
                    body.push(data);
                });
                serviceRootsResponse.on('end', onServiceRootsResultData);
                serviceRootsResponse.on('error', onRequestError);

                function onServiceRootsResultData() {
                    const serviceRootsResult = JSON.parse(Buffer.concat(body).toString());

                    const result =
                        // 1. get all resources of the specified linkrel
                        serviceRootsResult.resources?.[linkRel]
                            // 2. get the first resource that was registered with a system of the
                            // specified systemID and systemType
                            ?.filter(resource => resource.systems?
                                .filter(system => system.systemID === systemID
                                        && system.systemType === systemType).length > 0)
                            // 3. get the href and templated-status of the resource
                            .map(resource => {
                                return {href: resource.href, templated: resource.templated};
                            })
                            ?.[0];
                    resolve({"options": options, "href-data": result});
                }
            } else {
                const message = "Serviceroot request failed with '"
                                + serviceRootsResponse.statusMessage + "'";
                console.log(message);
                reject(new Error(message));
            }
        }
    });

    return deferred;
};

To fill in the URL template it is recommendable to use a special library (e.g. "url-template") instead of substring replacement:

findInRegistry(options, apiDomain, systemID, systemType, linkRel)
.then(function (uriTemplateFromRegistry) {
    // 4. Fill in the URL-template contained in the retrieved href to construct a URL:
    if (undefined !== uriTemplateFromRegistry["href-data"]
        && uriTemplateFromRegistry["href-data"].templated) {
        const assetURL
            = parseTemplate(uriTemplateFromRegistry["href-data"].href)
            .expand({"id": assetID});
        console.log("URL: " + assetURL);
        return {"options": uriTemplateFromRegistry.options, "url": assetURL};
    } else {
        console.log(linkRel + " not registered for system " + systemID
            + " and systemType " + systemType)
    }
    return {"options": uriTemplateFromRegistry.options, "url": null};
}
, () => console.log("End"));