Lesson 4B: Make an Automation app (TypeScript)





Introduction

Fusion Automation is one of Automation services provided by Autodesk Platform Services. As this tutorial written, there are five products supported by the Automation services. They are 3ds Max, Inventor, Fusion, Revit and AutoCAD. If you are interested in them, please visit the document.

In this tutorial, we’ll create an automation application with NodeJS and express allows online use to create designs themselves. We’ll be focused on Fusion automation related topics.

Please clone My First Fusion Plugin Automation repo. Some details like creating a website or loading environment variables will be ignored. The code for application payload could be found here. It is modified the sample from Lesson 3. You can check the changes between the code used for automation and the original sample script with GitHub.

If you have questions on these topics, please check documents for NodeJS, Express, jQuery, Bootstrap and documents of other libraries.

If you have experiences with other Automation services, there are few differences between Fusion and others. Fusion Automation uses Personal Access Token (PAT) and a different payload for arguments; we’ll cover it later in the tutorial.

Here is the final view of it. L4B-Overview.png

There are two parts of the UI. The developers/admin part are for the operators. It will create or remove the automation activity based on the script we’ve written before.

The consumer part is for the users. Please notice that Webhook address and Override PAT parts are just for the tutorial. In the production environment, the callback service should be configured on the server and PAT of user should have a better way to store it with other information of the customer.

Before we are going to create the website, we’ll need to make a small change to our script and prepare it for Automation.

Prepare for Automation

Save our design

We’ll need to save our boxes with Fusion Automation. Fusion Automation can create/update files in users’ project. It could be your own project or an authorized user. In this sample, we’ll simply save it in our first project’s root folder. Let’s add following codes in the end of our script.

 // Let's just save it to the root folder.
  const defaultFolder = app.data.dataProjects.item(0)?.rootFolder!;  
  app.activeDocument.saveAs('box.'+Date.now().toString(), defaultFolder, "A generated box", "");

  // Wait until everything has been done.
  while (app.hasActiveJobs) {
    wait(2000);
  }

Because it will take some time to save a design, we’ll need to wait it before Fusion Automation exits.

Create an AppBundle

We need to upload our script to the cloud for Fusion Automation. Fusion Automation uses AppBundle format. It is a zip file contains files in following structure:


|-- PackageContents.xml
|-- Contents
|   |-- main.ts

The PackageContents.xml contains a description of the add-in, including the GUIDs and relative path to the add-in. You can get more info from PackageContents.xml Format Reference.

Create a folder called CreateBox and create a PackageContents.xml file in it. Here is the content for our AppBundle:



  
      
      
      
  

Then we’ll create another folder called Contents in the CreateBox folder. Copy our script into it and rename it as main.ts.

Our AppBundle folder structure should be like below:

CreateBox
|-- PackageContents.xml
|-- Contents
|   |-- main.ts

Zip the whole CreateBox folder as CreateBox.zip (Not just the contents). Now, we have created the AppBundle. Let’s create our website. This section will follow the steps of Execute a Fusion Script walkthrough with some changes. Please check it out if you want more details about it.

Create our Automation application

To execute our script, we’ll need to create an activity first. It will define the AppBundle we are going to use and parameters our AppBundle needs.

Fusion Automation is a part of Autodesk platform services, and it requires access token to make changes to our applications.

There are two kinds of tokens, 2-Legged and 3-Legged. 2-Legged tokens are mainly for the applications while 3-Legged token allows your application to access authorized third-party data.

Create an application

First, we’ll need to create an application. After signing your account at aps.autodesk.com, click at your portrait. Click at My applications. L4B.Create.Application.Portrait.png

Click the Create application button. Following dialog will appear. L4B.Create.Application.Dialog.png

Please input a name for your application. We’ll choose Traditional Web App type.

In the next page, you’ll find Client ID and Client Secret fields. Please store them in the environment variables as APS_CLIENT_ID and APS_CLIENT_SECRET, we’ll use them very soon.

Before closing this page, we’ll need to add a callback address. Click Add URL L4B.Add.Callback.URL.png

Put following address in it:

http://localhost:3000/auth/callback

This url is for 3-legged authentication later we are going to implement. Don’t forget save the changes before closing the page.

Now we have our app created, we’ll need to get a two legged token first to access to it.

Obtain an access token for our application(2-Legged)

We’ll need to concreate client id and client secret with a colon character in between and encode it as base64. We’ll post it to the https://developer.api.autodesk.com/authentication/v2/token endpoint.

In our sample, they are loaded from environment variables and stored in appStatus. The code for 2-legged authentication looks like below:

const application = require('../app');
const appStatus = application.appStatus;
if (appStatus.initialized){
    const authCode = btoa(`${appStatus.clientID}:${appStatus.clientSecret}`);
    try {
        console.log('Attempting to get an access token.');
        const response = await fetch('https://developer.api.autodesk.com/authentication/v2/token', 
            {
                method: 'POST',
                body: 'grant_type=client_credentials&scope=code:all bucket:create bucket:read data:create data:write data:read',
                headers: {
                    'Content-Type' : 'application/x-www-form-urlencoded',
                    'Accept': 'application/json',
                    'Authorization': `Basic ${authCode}`
                }
            }
        )
        if (response.ok){            
            const payload = await response.json();
            this.expires = payload.expires_in as number * 1000 + Date.now();
            this.accessToken = payload.access_token as string;                
        }
    }
    catch(error){
        console.log(error);
    }
}

We need to provide scopes our application we need as grant_type in body. We also need to provide correct headers.

The success response contains a json, it will have an expiry time(expires_in) in seconds for the token, and an access token (access_token). We don’t need to care the token_type field in this tutorial.

We have our 2-legged token now, let’s continue with 3-legged token.

Obtain a 3-Legged token with PKCE

Here is an image of the public client PKCE flow in our document: authorization-code-3-legged-flow_public.png

We’ll need to generate code verifier and code challenge first, and redirect to the Autodesk login portal with code challenge.

In this sample, we are using a dict in memory to store a pair of verifier and challenge. Please use a memory cache with persist feature(e.g., Redis) in the production environment.

The verifier and challenge are encoded with base64url instead of base64. It is required when using them in a url.

generatePKCE(): {verifier: string; challenge: string} {
    const verifier = crypto.randomBytes(64).toString('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');

    const challenge = crypto.createHash('sha256')
        .update(verifier)
        .digest('base64')
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');

    return {verifier, challenge};
}

async doLogin(request: Request, response: Response, next: NextFunction)
{
    const {verifier, challenge} = this.generatePKCE();
    const state = crypto.randomBytes(16).toString('hex');

    ThreeLOController.pkceStore[state] = verifier;
    const application = require('../app');
    const appStatus = application.appStatus;

    const authUrl = new URL('https://developer.api.autodesk.com/authentication/v2/authorize');
    authUrl.searchParams.append('response_type', 'code');
    authUrl.searchParams.append('client_id', appStatus.clientID);
    authUrl.searchParams.append('redirect_uri', `http://localhost:${appStatus.port}/auth/callback`);
    authUrl.searchParams.append('scope', 'code:all bucket:create bucket:read bucket:update data:read data:write data:create user-profile:read viewables:read');
    authUrl.searchParams.append('state', state);
    authUrl.searchParams.append('code_challenge', challenge);
    authUrl.searchParams.append('code_challenge_method', 'S256');

    response.redirect(authUrl.toString());
}

This will redirect to the Autodesk login url with the parameters we set. For our sample, we required code:all bucket:create bucket:read bucket:update data:read data:write data:create user-profile:read viewables:read scopes.

The redirect_uri is used when the user finished login. It must be one of the URLS we added in the application’s callback addresses.

After receiving the callback, we’ll need to send verifier to the server get the 3-legged token.

async threeLegCallback(request : Request, response: Response, next: NextFunction) {

        const { code, state, error } = request.query;
  
        if (error) {
            response.status(401).send(`Authorization failed: ${error}`);
            return;
        } 
  
        if (typeof state !== 'string' || !ThreeLOController.pkceStore[state]) {
            response.status(400).send('Invalid state parameter');
            return;
        }

        const verifier = ThreeLOController.pkceStore[state];
        delete ThreeLOController.pkceStore[state];
        
        const application = require('../app');
        const appStatus = application.appStatus;
        // Prepare token request
        const tokenData = new URLSearchParams();
        tokenData.append('grant_type', 'authorization_code');
        tokenData.append('client_id', appStatus.clientID);
        tokenData.append('client_secret', appStatus.clientSecret);
        tokenData.append('code', code as string);
        tokenData.append('redirect_uri', `http://localhost:${appStatus.port}/auth/callback`);
        tokenData.append('code_verifier', verifier);
        
        try {
            const tokenResponse = await fetch('https://developer.api.autodesk.com/authentication/v2/token', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'accept': 'application/json'
                },
                body: tokenData.toString(),
             });
    
            if (!tokenResponse.ok) {
                const error = await tokenResponse.text();
                throw new Error(`Token exchange failed: ${tokenResponse.status} - ${error}`);
            }
            
            const tokens: TokenResponse = await tokenResponse.json();
            
            // Set secure HTTP-only cookies
            
            const userResponse = await fetch('https://api.userprofile.autodesk.com/userinfo', {
                method: 'GET',
                headers: {
                    'Authorization': `Bearer ${tokens.access_token}`
                }
            });
            if (!userResponse.ok) {
                const error = await userResponse.text();
                throw new Error(`Getting user info failed: ${userResponse.status} - ${error}`);
            }
            const profiles = await userResponse.json();
            response.cookie('username', profiles['preferred_username'], {
                httpOnly: true,
                secure: false,
                sameSite: 'lax',
                path: '/'
            });
            response.cookie('expires', (tokens.expires_in * 1000 + Date.now()).toString(), {
                httpOnly: true,
                secure: false,
                sameSite: 'lax',
                path: '/'
            });
            
            response.cookie('access_token', tokens.access_token, {
                httpOnly: true,
                secure: false, 
                sameSite: 'lax',
                path: '/'
            });
            
            response.cookie('refresh_token', tokens.refresh_token, {
                httpOnly: true,
                secure: false,
                sameSite: 'lax',
                path: '/'
            });

            response.redirect('/');
        } 
        catch (err: any) {
            response.status(500).send(`Token request failed: ${err.message}` );
        }    
    }

In the code above, we also get username from user’s profile. The access token is stored with httponly cookie. In the production environment, we should make it secure and using https instead of http.

If you are interested in the details, there is a document about cookies on the mozilla’s website.

At last, we’ll need to call logout API if the user wants to log out. We can simply delete cookies for our APP. It won’t log out the user from Autodesk Single Sign-on but only delete the 3-legged tokens.

In this sample, we’ll delete the cookies and log out from Autodesk. To achieve that, we need to call the logout API.

async doLogout(request : Request, response: Response, next: NextFunction) {
        const application = require('../app');
        const appStatus = application.appStatus;
        const logoutURL : string = `https://developer.api.autodesk.com/authentication/v2/logout?post_logout_redirect_uri=http://localhost:${appStatus.port}`;

        response.clearCookie('access_token');
        response.clearCookie('refresh_token');    
        response.clearCookie('username');            
        response.clearCookie('expires');        

        response.redirect(logoutURL);
    }

As the tutorial is written, the post_logout_redirect_uri only supports Autodesk domain. In the future, it may be available to other domains. Please check the latest document for the details.

Create an activity

Nickname and public key

Fusion Automation requires signature for the activity ID when submitting a 3LO working item. We’ll begin with creating a nickname and public key first.

To upload a public key for our application, we’ll need to provide its exponent and modulus in Base64 format. We can convert our existing keys in PEM format to JWK(JSON Web Key) format using a third party library. The modulus and exponent in JWK are stored as base64_url. We only need to convert them to Base64 before uploading it to APS.

import { existsSync, readFileSync, writeFileSync } from 'fs';
import { createPublicKey, generateKeyPairSync, type KeyObject } from 'crypto';

// We need keys to sign the activity
// We'll generate two keys in pem format for this demo.
// If you have jwk keys, you don't have to convert it to pem formats before using.
const PRIVATE_KEY_PATH = 'private.pem';
const PUBLIC_KEY_PATH = 'public.pem';

var privateKey : string = "";
var publicKey : string = "";

if (existsSync(PRIVATE_KEY_PATH) && existsSync(PUBLIC_KEY_PATH)) {
    privateKey = readFileSync(PRIVATE_KEY_PATH, 'utf-8');
    publicKey = readFileSync(PRIVATE_KEY_PATH, 'utf-8');
}
else {
  // We need a 2048 bit RSA key.
  const result = generateKeyPairSync('rsa', {
      modulusLength: 2048,
      publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
      privateKeyEncoding: { type: 'pkcs1', format: 'pem' }
  });

  privateKey = result.privateKey;
  publicKey = result.publicKey;

  writeFileSync(PRIVATE_KEY_PATH, privateKey);
  writeFileSync(PUBLIC_KEY_PATH, publicKey);
}

// We'll get modulus and exponent from JWK key.
// JWK is using base64url, we'll need to convert it to base64.

function base64urlToBase64(base64url: string):string {
  // Remove any existing padding
  const unpadded = base64url.replace(/=+$/, '');
    
  // Replace URL-safe characters
  var base64 = unpadded.replace(/-/g, '+').replace(/_/g, '/');
  const reminder = base64url.length % 4;

  if(reminder == 1){
    console.error("Invalid key!");
    throw new Error("Invalid key!");
  }
  else {
    const padding = (4 - reminder) % 4;
    for(let i = 0; i < padding; ++i ){
      base64 += "=";
    }
    return base64;
  }
}

const publicKeyObject = createPublicKey(publicKey);
const jwkPublicKeyBase64URL = publicKeyObject.export({format:"jwk"});
const jwkPublicKey = {e:base64urlToBase64(jwkPublicKeyBase64URL.e!), n:base64urlToBase64(jwkPublicKeyBase64URL.n!)};

Now, we can upload it while creating a nickname.

private async createNickname(authCode : string): Promise {
    // Let's check if the nickname exists
    console.log('Task 2 Create a nickname and upload public key.');
    let response = await fetch('https://developer.api.autodesk.com/da/us-east/v3/forgeapps/me', 
            {
                method: 'GET',
                headers: {                        
                    'Authorization': `Bearer ${authCode}`
                }
            });            
    if(response.ok) {
        const id = eval(await response.text());            
        let application = require('../app');
        let appStatus = application.appStatus;
        // The default nickname is client id.      
        // We'll also upload our public key here.
        const newId = `DA_${Date.now()}`;      
        if (id == appStatus.clientID) {
            response = await fetch('https://developer.api.autodesk.com/da/us-east/v3/forgeapps/me', 
            {
                method: 'PATCH',
                body: JSON.stringify({
                    nickname:newId,
                    publicKey: {
                        Exponent:appStatus.jwkPublicKey.e,
                        Modulus:appStatus.jwkPublicKey.n
                    }
                }),
                headers: {                        
                    'Authorization': `Bearer ${authCode}`,
                    'Content-Type': 'application/json'
                }
            });
            if(!response.ok) {
                console.log(await response.text());
                throw new Error('Failed to get/set nickname.');
            }
        }
        return newId;
    }
    else {
        throw new Error('Failed to get/set nickname.');
    }
}

We can also update the public only with the endpoint above, please check out our document.

Upload an Appbundle

To upload an Appbundle, first we’ll need to register one.

let response = await fetch('https://developer.api.autodesk.com/da/us-east/v3/appbundles', 
                {
                    method: 'POST',
                    body: JSON.stringify({
                        id: "CreateBox",
                        engine: "Autodesk.Fusion+Latest",
                        description: "Create a box with given dimensions."
                    }),
                    headers: {                        
                        'Authorization': `Bearer ${authCode}`,
                        'Content-Type': 'application/json'
                    }
                });

We’ll first post to https://developer.api.autodesk.com/da/us-east/v3/appbundles to register an AppBundle.

After the Appbundle is registered, we’ll get a response with required upload parameters for the AWS like below:

{
    "uploadParameters": {
        "endpointURL": "https://dasprod-store.s3.amazonaws.com",
        "formData": {
            "key": "apps//CreateBox/1",
            "content-type": "application/octet-stream",
            "policy": "eyJleHBpcmF0aW9uIjo... (truncated)",
            "success_action_status": "200",
            "success_action_redirect": "",
            "x-amz-signature": "27e07941f952... (truncated)",
            "x-amz-credential": "ASIATGVJZKM3N... (truncated)",
            "x-amz-algorithm": "AWS4-HMAC-SHA256",
            "x-amz-date": "20191128T083159Z",
            "x-amz-server-side-encryption": "AES256",
            "x-amz-security-token": "IQoJb3JpZ2luX2VjEGgaC... (truncated)"
        }
    },
    "id": ".CreateBox",
    "engine": "Autodesk.Fusion+Latest",
    "description": "Create a box with given dimensions.",
    "version": 1
}

We’ll need these parameters to upload our appbundle.

// Upload Endpoint
const url = result.uploadParameters.endpointURL;     
const blob = await openAsBlob('CreateBox.zip');    

// Convert the parameters in the result to a FormData for fetch.
const formData = new FormData();
Object.entries(result.uploadParameters.formData).forEach(([_key, value]) => {
    let key = _key;
    if (_key[0] == "'") {
        key = eval(_key);
    }
    formData.append(key, value as string);
});            

// Upload
formData.append('file',blob);
response = await fetch(url,{
    method: 'POST',                
    cache: 'no-cache',
    body: formData});

Now, we have uploaded our AppBundle. Before we can use it, we’ll need to create an alias for the Appbundle.

console.log('Step - 5 Create an alias for the AppBundle');
response = await fetch('https://developer.api.autodesk.com/da/us-east/v3/appbundles/CreateBox/aliases',{
     method: 'POST',
     body: JSON.stringify({version:1, id:'current_working_version'}),
     headers: {                        
        'Authorization': `Bearer ${authCode}`,
        'Content-Type': 'application/json'
    }
});

Now, we’ll create an activity for the AppBundle.

Publish an activity

An Activity is an action that can be executed in the Automation Service. It will tell Fusion Automation which AppBundle is appropriate to use and how to use it.

Like AppBundle, first we’ll need to create an Activity.

private async publishActivity(authCode: string, appbundle: string) {
    const payload = {
        id:'CreateBox',
        engine:"Autodesk.Fusion+Latest",
        commandline:[],
        parameters: {
            TaskParameters: {
                verb: "read",
                description: "the parameters for the script",
                required: true
            },
            PersonalAccessToken: {
                verb: "read",
                description: "the personal access token to use",
                required: true
            }
        },
        appbundles: [appbundle],
        settings: {},
        description: ""
    };
    
    let response = await fetch('https://developer.api.autodesk.com/da/us-east/v3/activities', 
            {
                method: 'POST',
                body: JSON.stringify(payload),
                headers: {                        
                    'Authorization': `Bearer ${authCode}`,
                    'Content-Type': 'application/json'
                }
            });
    ...

Here we can set the AppBundle to use and parameter it takes.

For Fusion Automation, it always uses at least two parameters. The first one is TaskParameters, it is the input our script will take. In this tutorial, it accepts the input similar to the CreateBox.json we’ve created in the Lesson 3. Another one is Personal Access Token, it will be used to access user data when a work item is submitted.

For more settings about the activity payload, please check the document about Post activities.

After we’ve created an activity, we’ll need to create an alias to use it. The Automation API does not let you reference an Activity by its id. You must always reference an Activity by an alias. Note that an alias points to a specific version of an Activity and not the Activity itself.

response = await fetch('https://developer.api.autodesk.com/da/us-east/v3/activities/CreateBox/aliases', 
    {
        method: 'POST',
        body: JSON.stringify({version:1, id:'current_working_version'}),
        headers: {                        
            'Authorization': `Bearer ${authCode}`,
            'Content-Type': 'application/json'
        }
    });

We are referencing version 1 as current_working_version.

Now we have created our Automation application, we’ll show how to call it then.

Upload a work item

After the user has submitted their parameters on the web, we’ll use them to execute our app.

There are two different kinds of users to submit a work item. One is executed as our application’s user. In that case, we don’t need to use a 3LO key and PAT. The output will be stored to our Fusion Data Hub.

On the other hand, if the user executes our application on its behalf, we’ll need to use the user’s PAT and Authcode. We also need to call our activity with the signed activity id. The activity id is signed with the public key we uploaded during create a nickname.

Here is how to sign it with TypeScript.

// The activityId should be convert to UTF16 encoding before signing it. 
signActivity(privateKey: string, activityId: string): string {        
    const buffer = Buffer.from(activityId, 'utf16le');
    const signer = createSign('RSA-SHA256');
    signer.update(buffer);
    signer.end();
    return signer.sign(privateKey, 'base64');
}

Let’s create our work item now.

async createWorkItem(request : Request, response: Response, next: NextFunction) {
    let application = require('../app');
    let appStatus = application.appStatus;

    response.type('text/javascript');
    if (appStatus.errors.length != 0){
        response.send(`{'error':${JSON.stringify(appStatus.errors)}}`);
    }
    else {
        var authCode = await AuthenticationHelper.getAccessToken();
        if (!authCode) {
            response.statusCode = 500;
            response.send("{'error':'Failed to authenticate!'}");
        }
        else {
            const params = request.body;
            const taskParameters = {
                                "border_thickness": Number(params.border_thickness),
                                "bottom_thickness" : Number(params.bottom_thickness),
                                "inner_width": Number(params.inner_width),
                                "inner_length" : Number(params.inner_length),
                                "bottom_inner_height" : Number(params.bottom_inner_height),
                                "lid_inner_height" : Number(params.lid_inner_height),
                                "fillet_size" : Number(params.fillet_size),
                                "input_units" : params.input_units
                            };         
            let pat = appStatus.fusionPAT;
            var payload : WorkitemPayload = {
                    activityId: params.activityId,                       
                    arguments: {
                        TaskParameters: JSON.stringify(taskParameters)                                  
                    }
            };        
            // If we are overriding PAT, we'll need to use a three leg token.
            if (params.pat != ""){
                const threeloToken = await AuthenticationHelper.getThreeLeggedToken(response, request);
                if(threeloToken != undefined){
                    pat = params.pat;
                    authCode = threeloToken;           
                    const signedActivityId = this.signActivity(appStatus.privateKey, params.activityId);       
                    payload.signatures =  { activityId: signedActivityId };       
                }
            }
            payload.arguments.PersonalAccessToken = pat;                          
            
            // In the production environment, we could store signed activity id instead of signing it on the fly.                                              
            if (params.webhook != "")
            {
                payload.arguments.onComplete = 
                payload.arguments.onProgress = {
                            verb: 'post',
                            url: params.webhook,
                        };
            
            }                

            const result = await fetch ('https://developer.api.autodesk.com/da/us-east/v3/workitems', {
                method: 'POST',
                body: JSON.stringify(payload),
                headers: {
                    'Authorization': `Bearer ${authCode}`,
                    'Content-Type': 'application/json'
                }
            });
            if (result.ok) {
                const data = await result.json();
                console.log(data);
                response.send(data as string);
            }
            else {
                console.log(await result.text());
                response.statusCode = 500;
                response.send("{\"error\":\"Failed to create a workitem.\"}");
            }
        }
    }        
}

We can create webhooks to receive the notifications from the Fusion Automation service. In this tutorial, we won’t receive it with our application directly. Please use a third party tool to catch it(e.g., webhook.site).

Now we have finished the basics of creating and using Fusion Automation. There are more details about other endpoints in the repo. Please check it out!


Comments

Leave a Reply

Discover more from Autodesk Developer Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading