A Mendix SDK Primer — Part 1

A Mendix SDK Primer — Part 1

The Mendix Model SDK, supported by the Mendix Platform SDK, is a tool that has been around for years. Its purpose is to provide developers with programmatic access to the model of a Mendix app, without using Studio or Studio Pro.

In this short series, I shall illustrate how SDKs can be used to perform useful operations against an app. This first post is to get you started by setting up a development environment using NodeJS, creating a TypeScript script to use the SDK as well as executing that script.

I don’t intend this to be a TypeScript/JavaScript tutorial (I’m hardly in a good position to do that and there are lots of fine resources available online) and I will focus on the aspects of the SDK rather than the code details.

Why might you want to use the SDK?

There are a large number of use cases that the Mendix Model SDK can support, to name just a few:

The extraction of the details of all or part of an app model for translation into a different medium. For example, you may wish to extract information from the model to produce your own documentation, or you might wish to extract the logic in microflows to build equivalents in another language such as JavaScript or C#.

The automated updating of apps to enforce compliance with development or security standards. For example, you might want to enforce common microflow/nanoflow naming standards or require that Entities have appropriate minimum access permissions applied.

The automated creation of Code, Pages, Entities, etc. in an app from parameterized input. For example, you may want to automate the process of copying the structure or schema of a data source and build that into a Mendix app.

Development Environment

Developers have their own preferences when it comes to tools. You will need NodeJS and a script editor as a minimum — I use Visual Studio Code for editing, available from Visual Studio Code, as I like its TypeScript support. I will not suggest any fancy setup and configuration for the environment but just keep things simple — a single folder to hold the scripts that I build.

There is a lot of documentation relating to the Platform and Model SDKs available on the docs page: Mendix Platform SDK.

NodeJS Installation

Download and install the latest stable version of NodeJS which can be found at NodeJS with the English language download page at NodeJS English Downloads.

If you have an earlier version of NodeJS already installed and wish to retain it then you can use a tool such as the NodeJS Version Manager ‘nvm’ which will allow you to install and manage multiple versions of NodeJS and switch between them. A selection of package managers is described at NodeJS Package Managers.

Create a working folder and initialize it

Next, I need somewhere to put my work so I create a folder to work in. Then I initialize that with the NodeJS package manager and ensure that the TypeScript package is installed.

mkdir SDKBlog
cd SDKBlog
npm init --yes
npm install -g typescript

Next switch over to your editor and create or edit a file in the folder called package.json and change that file to include dependencies for the SDK packages. It should look something like this:

{
    "name": "sdkblog",
    "version": "1.0.0",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "mendixmodelsdk": "^4.56.0",
        "mendixplatformsdk": "^5.0.0"
    },
    "devDependencies": {},
    "description": ""
}

Download the SDK packages by using npm install. This will create a sub-folder called ‘node_modules’ where a hierarchy of various package files will be stored.

npm install

Finally, create or edit a tsconfig.json file to specify the compiler options and the name of the TypeScript file being created. Each time you add a new TypeScript file to the folder you can add it to the tsconfig.json file and then when you run the TypeScript compiler command ‘tsc’ it will compile all the files into JavaScript so that they can be executed.

{
    "compilerOptions" : {
        "module" : "commonjs",
        "target" : "es2020",
        "strict": true
    },
    "files" : [
        "showdocument.ts"
    ]
}

Get a Personal Access Token

You will need to go to the Mendix Warden site at Mendix Warden. Once there, you will need to log in with your Mendix Developer Portal credentials.

Create a Personal Access Token to access the repository functions, for example:

Save the generated token in an environment variable called MENDIX_TOKEN. Instructions on how to do this are available on the Mendix PAT Setup Page.

Having completed this you should now be ready to use the SDKs.

App to JavaScript Script

The script I’ll write here is a useful tool that you can use when you are doing any Mendix SDK work.

It will pull down the model from an existing Mendix app and find a document in the model that you specify and output the definition of that document as JavaScript code.

Believe me, when you start working with the Mendix SDK you will probably use this time and again as there is nothing better than eyeballing an existing example to enhance your understanding of how to use the SDK and the model of an app.

The script is on Github and the link to the Github project is at the end of this blog posting below.

Preliminaries

The script opens by expecting the command line to hold a project nickname (any name you want) and the qualified name of the Mendix document (microflow/form/enumeration) — so this will be just the module name if you want the domain model extracted, or the module name plus a period plus the document name. If you are accessing a different project for the first time you will need to add the app ID to the command line (taken from the General tab on the Mendix Developer portal page for the app), and a branch name if you don’t want to use the default branch.

import { JavaScriptSerializer } from "mendixmodelsdk";
import { MendixPlatformClient, OnlineWorkingCopy } from "mendixplatformsdk";
import * as fs from "fs";
// Usage: node showdocument.js nickname documentname appID branch
//   nickname is your own name for the app
//   documentname if the qualified name (module.document) of the document to serialize
//   appID is the appID for the app (taken from the Mendix developer portal page)
//   branch is the name of the branch to use
//
// The appID and branch are only needed when setting up a new working copy
//
// The appID, branch name and working copy ID are saved in a file called nickname.workingcopy in the
// current folder so they can be used next time if possible
//
const args = process.argv.slice(2);
main(args);
async function main(args: string[])
{
    var appID = "";
    var branch = "";
    var documentname = "";
    if (args.length < 1)
    {
        console.log(`Need at least a nickname and document name on the command line`);
        return;
    }
    const nickname = args[0].split(' ').join('');
    documentname = args[1];
    if (args.length > 2)
        appID = args[2];
    if (args.length > 3)
        branch = args[3];
    const workingCopyFile = nickname + '.workingcopy';
    var wcFile;
    var wcID;
    try
    {
        wcFile = fs.readFileSync(workingCopyFile).toString();
        appID = wcFile.split(':')[0];
        branch = wcFile.split(':')[1];
        wcID = wcFile.split(':')[2];
    }
    catch
    {
        wcFile = "";
        wcID = "";
        if (appID === "")
        {
            console.log("Need an appID on the command line if no workingcopy file is present for the nickname");
            return;
        }
    }

When the script is run, it will create a file named with the nickname you gave + ‘.workingcopy’, and in there it will store the app ID, the branch name, and the working copy id that is generated. This is so that, the next time you run the script for the same app (nickname) it will read in the working copy id you created last and use that again. This makes the process much faster.

    const client = new MendixPlatformClient();
    var workingCopy:OnlineWorkingCopy;
    const app = client.getApp(appID);
    var useBranch = branch;
    if (wcID != "")
    {
        try
        {
            console.log("Opening existing working copy");
            workingCopy = app.getOnlineWorkingCopy(wcID);
        }
        catch (e)
        {
            console.log(`Failed to get existing working copy ${wcID}: ${e}`);
            wcID = ""
        }
    }
    if (wcID === "")
    {
        const repository = app.getRepository();
        if ((branch === "") || (branch === "trunk") || (branch === "main"))
        {
            const repositoryInfo = await repository.getInfo();
            if (repositoryInfo.type === "svn")
                useBranch = "trunk";
            else
                useBranch = "main";
        }
        try
        {
            workingCopy = await app.createTemporaryWorkingCopy(useBranch);
            wcID = workingCopy.workingCopyId;
        }
        catch (e)
        {
            console.log(`Failed to create new working copy for app ${appID}, branch ${useBranch}: ${e}`);
            return;
        }
    }
    fs.writeFileSync(workingCopyFile, `${appID}:${useBranch}:${wcID}`);

Using the SDK

Having created/opened the working copy, the script now opens the model for the app, finds the domain model or the document specified, calls the JavaScript deserializer and writes the output to the console.

Note that the script assumes that a document name without a period character is a module name and you want the domain model for that module extracted. Otherwise, the supplied name is regarded as a qualified document name (module.document).

    const model = await workingCopy!.openModel();
    console.log(`Opening ${documentname}`);
    if (documentname.split(".").length <= 1)
    {
        const domainmodelinterfaces = model.allDomainModels().filter(dm => dm.containerAsModule.name === documentname);
        if (domainmodelinterfaces.length < 1)
            console.log(`Cannot find domain model for ${document}`);
        else
        {
            try
            {
                const domainmodelinterface = domainmodelinterfaces[0];
                const domainmodel = await domainmodelinterface.load();
                 console.log(JavaScriptSerializer.serializeToJs(domainmodel));
            }
            catch(e)
            {
                console.log(`Error occured: ${e}`);
            }
        }
    }
    else
    {
        const documentinterfaces = model.allDocuments().filter(doc => doc.qualifiedName === documentname);
        if (documentinterfaces.length < 1)
            console.log(`Cannot find document for ${document}`);
        else
        {
            try
            {
                const documentinterface = documentinterfaces[0];
                const document = await documentinterface.load();
                console.log(JavaScriptSerializer.serializeToJs(document));
            }
            catch(e)
            {
                console.log(`Error occured: ${e}`);
            }
        }
    }
}

Examples

Before running the script, or after you have modified the script you should compile it from TypeScript into JavaScript using the command ‘tsc’. This will work provided you have the TypeScript file name included in the tsconfig.json as described earlier.

tsc

Otherwise, you can compile a specific TypeScript file using a command like:

tsc showdocument.ts

First, I pulled down the domain model of the Administration model using the following command. I chose ‘fred’ as my nickname for the app.

node showdocument.js fred Administration 8252db0e-6235-40a5-9502-36e324c618d7

The output can be very long so I shall just show a part of it.

    var generalization1 = domainmodels.Generalization.create(model);
    // Note: this is an unsupported internal property of the Model SDK which is subject to change.
     generalization1.__generalization.updateWithRawValue("System.User");
    var stringAttributeType1 = domainmodels.StringAttributeType.create(model);
    var storedValue1 = domainmodels.StoredValue.create(model);
    var fullName1 = domainmodels.Attribute.create(model);
    fullName1.name = "FullName";
    fullName1.type = stringAttributeType1;   // Note: for this property a default value is defined.
    fullName1.value = storedValue1;   // Note: for this property a default value is defined.
    var stringAttributeType2 = domainmodels.StringAttributeType.create(model);
    var storedValue2 = domainmodels.StoredValue.create(model);
    var email1 = domainmodels.Attribute.create(model);
    email1.name = "Email";
    email1.type = stringAttributeType2;   // Note: for this property a default value is defined.
    email1.value = storedValue2;   // Note: for this property a default value is defined.
    var booleanAttributeType1 = domainmodels.BooleanAttributeType.create(model);
    var storedValue3 = domainmodels.StoredValue.create(model);
    storedValue3.defaultValue = "true";
    var isLocalUser1 = domainmodels.Attribute.create(model);
    isLocalUser1.name = "IsLocalUser";
    isLocalUser1.type = booleanAttributeType1;   // Note: for this property a default value is defined.
    isLocalUser1.value = storedValue3;   // Note: for this property a default value is defined.
    var memberAccess1 = domainmodels.MemberAccess.create(model);
    memberAccess1.attribute = model.findAttributeByQualifiedName("Administration.Account.FullName");
    memberAccess1.accessRights = domainmodels.MemberAccessRights.ReadWrite;
    var memberAccess2 = domainmodels.MemberAccess.create(model);
    memberAccess2.attribute = model.findAttributeByQualifiedName("Administration.Account.Email");
    memberAccess2.accessRights = domainmodels.MemberAccessRights.ReadWrite;
    var memberAccess3 = domainmodels.MemberAccess.create(model);
    memberAccess3.attribute = model.findAttributeByQualifiedName("Administration.Account.IsLocalUser");
    memberAccess3.accessRights = domainmodels.MemberAccessRights.ReadOnly;
    var accessRule1 = domainmodels.AccessRule.create(model);
    accessRule1.memberAccesses.push(memberAccess1);
    accessRule1.memberAccesses.push(memberAccess2);
    accessRule1.memberAccesses.push(memberAccess3);
    accessRule1.moduleRoles.push(model.findModuleRoleByQualifiedName("Administration.Administrator"));
    accessRule1.allowCreate = true;
    accessRule1.allowDelete = true;
    var memberAccess4 = domainmodels.MemberAccess.create(model);
    memberAccess4.attribute = model.findAttributeByQualifiedName("Administration.Account.FullName");
    memberAccess4.accessRights = domainmodels.MemberAccessRights.ReadOnly;
    var memberAccess5 = domainmodels.MemberAccess.create(model);
    memberAccess5.attribute = model.findAttributeByQualifiedName("Administration.Account.Email");
    memberAccess5.accessRights = domainmodels.MemberAccessRights.ReadOnly;
    var memberAccess6 = domainmodels.MemberAccess.create(model);
    memberAccess6.attribute = model.findAttributeByQualifiedName("Administration.Account.IsLocalUser");
    var accessRule2 = domainmodels.AccessRule.create(model);
    accessRule2.memberAccesses.push(memberAccess4);
    accessRule2.memberAccesses.push(memberAccess5);
    accessRule2.memberAccesses.push(memberAccess6);
    accessRule2.moduleRoles.push(model.findModuleRoleByQualifiedName("Administration.User"));
    accessRule2.defaultMemberAccessRights = domainmodels.MemberAccessRights.ReadOnly;
    var memberAccess7 = domainmodels.MemberAccess.create(model);
    memberAccess7.attribute = model.findAttributeByQualifiedName("Administration.Account.FullName");
    memberAccess7.accessRights = domainmodels.MemberAccessRights.ReadWrite;
    var memberAccess8 = domainmodels.MemberAccess.create(model);
    memberAccess8.attribute = model.findAttributeByQualifiedName("Administration.Account.Email");
    var memberAccess9 = domainmodels.MemberAccess.create(model);
    memberAccess9.attribute = model.findAttributeByQualifiedName("Administration.Account.IsLocalUser");
    var accessRule3 = domainmodels.AccessRule.create(model);
    accessRule3.memberAccesses.push(memberAccess7);
    accessRule3.memberAccesses.push(memberAccess8);
    accessRule3.memberAccesses.push(memberAccess9);
    accessRule3.moduleRoles.push(model.findModuleRoleByQualifiedName("Administration.User"));
    accessRule3.xPathConstraint = "[id='[%CurrentUser%]']";
    var account1 = domainmodels.Entity.create(model);
    account1.name = "Account";
    account1.location = {"x":220,"y":140};
    account1.generalization = generalization1;   // Note: for this property a default value is defined.
    account1.attributes.push(fullName1);
    account1.attributes.push(email1);
    account1.attributes.push(isLocalUser1);
    account1.accessRules.push(accessRule1);
    account1.accessRules.push(accessRule2);
    account1.accessRules.push(accessRule3);

Then I pulled down the ChangeMyPassword microflow from the model by running the following command. Note that no app ID was needed as it could use the existing working copy that has just been created.

node showdocument.js fred Administration.ChangeMyPassword

This output is even longer so again I will just cut out part of the results.

    var expressionSplitCondition1 = microflows.ExpressionSplitCondition.create(model);
    expressionSplitCondition1.expression = "$AccountPasswordData/NewPassword = $AccountPasswordData/ConfirmPassword";
    var exclusiveSplit1 = microflows.ExclusiveSplit.create(model);
    exclusiveSplit1.relativeMiddlePoint = {"x":430,"y":200};
    exclusiveSplit1.size = {"width":130,"height":80};
    exclusiveSplit1.splitCondition = expressionSplitCondition1;   // Note: for this property a default value is defined.
    exclusiveSplit1.caption = "Passwords equal?";
    var translation1 = texts.Translation.create(model);
    translation1.languageCode = "en_US";
    translation1.text = "The new passwords do not match.";
    var translation2 = texts.Translation.create(model);
    translation2.languageCode = "nl_NL";
    translation2.text = "De nieuwe wachtwoorden komen niet overeen.";
    var text1 = texts.Text.create(model);
    text1.translations.push(translation1);
    text1.translations.push(translation2);
    var textTemplate1 = microflows.TextTemplate.create(model);
    textTemplate1.text = text1;   // Note: for this property a default value is defined.
    var showMessageAction1 = microflows.ShowMessageAction.create(model);
    showMessageAction1.template = textTemplate1;   // Note: for this property a default value is defined.
    showMessageAction1.type = microflows.ShowMessageType.Error;
    var actionActivity1 = microflows.ActionActivity.create(model);
    actionActivity1.relativeMiddlePoint = {"x":430,"y":75};
    actionActivity1.size = {"width":120,"height":60};
    actionActivity1.action = showMessageAction1;
    var endEvent1 = microflows.EndEvent.create(model);
    endEvent1.relativeMiddlePoint = {"x":430,"y":-20};
    endEvent1.size = {"width":20,"height":20};
    var startEvent1 = microflows.StartEvent.create(model);
    startEvent1.relativeMiddlePoint = {"x":-220,"y":200};
    startEvent1.size = {"width":20,"height":20};
    var closeFormAction1 = microflows.CloseFormAction.create(model);
    var actionActivity2 = microflows.ActionActivity.create(model);
    actionActivity2.relativeMiddlePoint = {"x":1110,"y":200};
    actionActivity2.size = {"width":120,"height":60};
    actionActivity2.action = closeFormAction1;
    var endEvent2 = microflows.EndEvent.create(model);
    endEvent2.relativeMiddlePoint = {"x":1230,"y":200};
    endEvent2.size = {"width":20,"height":20};
    var memberChange1 = microflows.MemberChange.create(model);
    // Note: this is an unsupported internal property of the Model SDK which is subject to change.
    memberChange1.__attribute.updateWithRawValue("System.User.Password");
    memberChange1.value = "$AccountPasswordData/NewPassword";
    var changeObjectAction1 = microflows.ChangeObjectAction.create(model);
    changeObjectAction1.items.push(memberChange1);
    changeObjectAction1.refreshInClient = true;
    changeObjectAction1.commit = microflows.CommitEnum.Yes;
    changeObjectAction1.changeVariableName = "Account";
    var actionActivity3 = microflows.ActionActivity.create(model);
    actionActivity3.relativeMiddlePoint = {"x":620,"y":200};
    actionActivity3.size = {"width":120,"height":60};
    actionActivity3.action = changeObjectAction1;
    actionActivity3.caption = "Save password";
    actionActivity3.autoGenerateCaption = false;
    var expressionSplitCondition2 = microflows.ExpressionSplitCondition.create(model);
    expressionSplitCondition2.expression = "$OldPasswordOkay";
    var exclusiveSplit2 = microflows.ExclusiveSplit.create(model);
    exclusiveSplit2.relativeMiddlePoint = {"x":230,"y":200};
    exclusiveSplit2.size = {"width":120,"height":80};
    exclusiveSplit2.splitCondition = expressionSplitCondition2;   // Note: for this property a default value is defined.
    exclusiveSplit2.caption = "Old password okay?";
    var endEvent3 = microflows.EndEvent.create(model);
    endEvent3.relativeMiddlePoint = {"x":230,"y":-20};
    endEvent3.size = {"width":20,"height":20};
    var basicCodeActionParameterValue1 = microflows.BasicCodeActionParameterValue.create(model);
    basicCodeActionParameterValue1.argument = "$Account/Name";
    var javaActionParameterMapping1 = microflows.JavaActionParameterMapping.create(model);
    // Note: this is an unsupported internal property of the Model SDK which is subject to change.
    javaActionParameterMapping1.__parameter.updateWithRawValue("System.VerifyPassword.userName");
    javaActionParameterMapping1.parameterValue = basicCodeActionParameterValue1;   // Note: for this property a default value is defined.
    var basicCodeActionParameterValue2 = microflows.BasicCodeActionParameterValue.create(model);
    basicCodeActionParameterValue2.argument = "$AccountPasswordData/OldPassword";
    var javaActionParameterMapping2 = microflows.JavaActionParameterMapping.create(model);
    // Note: this is an unsupported internal property of the Model SDK which is subject to change.
    javaActionParameterMapping2.__parameter.updateWithRawValue("System.VerifyPassword.password");
    javaActionParameterMapping2.parameterValue = basicCodeActionParameterValue2;   // Note: for this property a default value is defined.

OK, so these snippets show that the output can be rather extensive and it may hint at some of the complexity that can come from using the SDK, though if you are familiar with using JavaScript/TypeScript then you will probably feel much more comfortable.

Similar commands can be used to extract the definitions for the various document types within a Mendix app. The JavaScriptSerializer can provide you with code that might be used directly (or after modification) to update this or another app, which is extremely useful. But…

A Touch of Caution

Using the JavaScriptSerializer to pull out a part of your model and convert it into JavaScript is great for showing you how you might go about performing similar actions against a model. Right? Well almost.

The code generated will work nearly all the time, but there are some actions that will not be rendered in exactly the way that you have to write a script updating a model. For example, I have found that the code generated is not executable against a model where the script uses the System module.

The SDK cannot be used to access the System module for reading or writing, which is a complication. There are workarounds that permit you to refer to something in the System module, so for example in the domain model of Administration above at the top of the script a domainmodels.Generalization is created which refers to System.User and which is later used when creating the specialization named Account at the bottom of the script. The syntax given in the script is correct but currently will be rejected if you try to run it. You need to replace:

generalization1.__generalization.updateWithRawValue("System.User");

with:

(generalization1 as any)["_generalization"].updateWithRawValue("System.User");

There are similar instances with setting Microflow call parameters and referring to attributes in System Entities through specializations. I expect there are other places where this type of issue may come up. These matters are on the backlog for fixing but for the time being you will need to use a workaround, such as this one just above👆.

Summary

I hope you find this blog post and script useful. In my next SDK post, I will write a script to create a brand-new app, and add a module to it along with a domain model and some simple support documents.

The folder and script used to produce this blog post, along with the output from the example commands, are available on GitHub.