Managing Fact Sheet Tags Dynamically Based on Relation Changes

Learn how to set up an event-triggered automation that assigns or removes fact sheet tags on creating or removing relations to a specific fact sheet type.

Overview

To indicate the relationships between fact sheets more clearly, you can assign corresponding tags to them once a relation is established or delete tags once the relation is removed.

In this tutorial, you'll learn how to automate the process of assigning and removing tags on creating or removing relations using a Python script that is triggered by a webhook.

As an example scenario, we assume that an organization categorizes IT components as either Server or Database, tagging them accordingly. When an application fact sheet is linked to an IT component, the script automatically assigns a tag reflecting the IT component type: Linked_to_Server or Linked_to_Database. If the relation is deleted, the tag is automatically removed.

This tagging system allows stakeholders to swiftly comprehend the landscape of dependencies within your IT architecture. It's especially beneficial when planning system maintenance, scaling operations, or conducting security audits. Knowing whether an application is linked to a server type or a database type IT component can affect decisions and strategies.

Prerequisites

Before you start, do the following:

  • Get admin access to your SAP LeanIX workspace.
  • Obtain an API token by creating a technical user with the admin role. For more information, see Technical Users.

This tutorial assumes you have basic knowledge of:

  • GraphQL
  • Webhooks
  • Python

Step 1: Create Tag Groups

Create the necessary tag groups with tags according to your organization's requirements. To learn how to manage tag groups in SAP LeanIX, see Tagging.

In our example scenario, we need to create two tag groups, as shown in the following table. Restrict each group to a specific fact sheet type and set the group mode to Single.

Tag GroupTags
IT component tag- Server
- Database
Application tag- Linked_to_Server
- Linked_to_Database

📘

Note

In this tutorial, we assume that tags are already assigned to IT component fact sheets.

Step 2: Create a Webhook for Fact Sheet Relations

Create a PUSH webhook for the RELATION_CREATED and RELATION_UPDATED events. For instructions, see Creating a Webhook.

To test the automation, use a test target URL. If the automation works as expected, replace the test URL with the function URL.

Step 3: Create and Deploy a Function

Create a function to assigns tags to applications once they are linked to IT components or remove tags once the relation is removed. You can deploy the function using your preferred method, such as through a Function as a Service (FaaS) provider. For instructions, refer to the documentation of your FaaS provider.

The script provided in this tutorial performs the following tasks:

  • Authenticates to SAP LeanIX services

  • Parses the webhook payload for the IDs of related fact sheets

  • Retrieves the IDs of tags using a GraphQL query

  • Identifies tags assigned to IT components using a GraphQL query

  • Assigns or removes the appropriate tag on application fact sheets using a GraphQL mutation. For an example mutation, see Adding Tags to a Fact Sheet.

Example code:

import logging
import os

import requests

logging.basicConfig(level=logging.DEBUG)

# Request timeout
TIMEOUT = 20

# API token and Subdomain are set as env variables.
# It is adviced not to hard code sensitive information in your code.
LEANIX_API_TOKEN = os.getenv('LEANIX_API_TOKEN')
LEANIX_SUBDOMAIN = os.getenv('LEANIX_SUBDOMAIN')
LEANIX_FQDN = f'https://{LEANIX_SUBDOMAIN}.leanix.net/services'

# OAuth2 URL to request the access token.
LEANIX_OAUTH2_URL = f'{LEANIX_FQDN}/mtm/v1/oauth2/token'

# GraphQL Endpoint
LEANIX_GRAPHQL_URL = f'{LEANIX_FQDN}/pathfinder/v1/graphql'

# The IDs of the tags that will be assigned to the application factsheet
LINKED_TO_SERVER_ID = os.getenv('LINKED_TO_SERVER_ID')
LINKED_TO_DATABSE_ID = os.getenv('LINKED_TO_DATABSE_ID')

# Names of the tags of the IT Component
ITC_SERVER_TAG = 'Server'
ITC_DATABASE_TAG = 'Database'

def obtain_access_token() -> str:
    """Obtains a LeanIX Access token using the Technical User generated
    API secret.
    Returns:
        str: The LeanIX Access Token
    """
    if not LEANIX_API_TOKEN:
        raise Exception('A valid token is required')
    response = requests.post(
        LEANIX_OAUTH2_URL,
        auth=('apitoken', LEANIX_API_TOKEN),
        data={'grant_type': 'client_credentials'},
        timeout=TIMEOUT
    )
    response.raise_for_status()
    return response.json().get('access_token')

def _request_headers() -> dict:
    """Generates the necessary headers for interacting with the LeanIX GraphQL API
    Returns:
        dict: A dictionary with the headers required for making the request
    """
    access_token = obtain_access_token()
    auth_header = f'Bearer {access_token}'
    # Provide the headers
    headers = {
        'Authorization': auth_header,
    }
    return headers

def execute_mutation(mutation: str, variables: dict) -> dict:
    """This function executes a mutation request on a GraphQL endpoint. 
    Args:
        mutation (str): The GraphQL mutation to be performed.
        variables (dict): The variables to be used in the mutation.
    Returns:
        dict: The JSON response from the GraphQL endpoint as a dictionary.
    Raises:
        HTTPError: If the request to the GraphQL endpoint results in an HTTP error.
    """
    headers = _request_headers()
    # In practice with the GraphQL API, mutations are technically not placed under 
    # a 'mutation' field. Instead, they are placed under the `query` attribute.
    response = requests.post(
        LEANIX_GRAPHQL_URL,
        headers=headers,
        json={'query': mutation, 'variables': variables},
        timeout=TIMEOUT
    )
    response.raise_for_status()
    return response.json()

def get_tags(id: str) -> dict:
    """This function queries the tags of the given IT Component Fact Sheet.

    Args:
        id (str): The id of the IT Component Fact Sheet that is to be read.

    Returns:
        dict: Result of the query as a dict.
    """    
    query = """
        {
        factSheet(id: "%s") {
            id
            rev
            ... on ITComponent {
              tags {
                name
              }
            }
            tags {
              tagId: id
            }
        }
        }
    """ % (id)
    return execute_mutation(query, {})

def parse_tags(tags: dict) -> str:
    """This function reads the tag dictionary of a given query response and determines if a certain tag is present.

    Args:
        tags (dict): The tag dictionary of a IT Component Fact Sheet.

    Returns:
        str: ID of the tag the it component has set.
    """    
    tag_list = tags.get("data").get("factSheet").get("tags")
    for tag in tag_list:
        if tag.get("name") == ITC_SERVER_TAG:
            return LINKED_TO_SERVER_ID
        elif tag.get("name") == ITC_DATABASE_TAG:
            return LINKED_TO_DATABSE_ID
    return ''

def remove_tag(id: str) -> dict:
    """This function cleans up the tags of the desired tag group before any further updates.

    Args:
        id (str): The id of the Fact Sheet that will have its tags removed.

    Returns:
        dict: The updated Fact Sheet as a dictionary.
    """    
    mutation = """
        mutation DeleteTags($id: ID!, $patches: [Patch]!, $validateOnly: Boolean) {
            updateFactSheet(id: $id, patches: $patches, validateOnly: $validateOnly) {
                factSheet {
                    name
                }
            }
        }
    """
    variables = {
        'id': id,
        'patches': [
            {
                'op': 'remove',
                'path': '/tags',
                'value': '[{\"tagId\":\"' + LINKED_TO_SERVER_ID + '\"}, {\"tagId\":\"' + LINKED_TO_DATABSE_ID + '\"}]'
            }
        ],
        'validateOnly': False
    }
    return execute_mutation(mutation, variables)

def set_tag(id: str, itc_tag_id: str) -> dict:
    """This function sets the tag of the Application Fact Sheet based on the previously determined type of the IT Component.

    Args:
        id (str): The id of the Fact Sheet that is to be updated.
        itc_tag_id (str): The id of the tag that will be assigned to the Fact Sheet.

    Raises:
        e: Exception: If no correct tag was read from the IT Component an Exception is raised.

    Returns:
        dict: The updated Fact Sheet as a dictionary.
    """      
    mutation = """
        mutation UpdateTags($id: ID!, $patches: [Patch]!, $validateOnly: Boolean) {
            updateFactSheet(id: $id, patches: $patches, validateOnly: $validateOnly) {
                factSheet {
                    name
                }
            }
        }
    """
    variables = {
        'id': id,
        'patches': [
            {
                'op': 'add',
                'path': '/tags',
                'value': '[{\"tagId\":\"'+ itc_tag_id +'\"}]'
            }
        ],
        'validateOnly': False
    }
    try:
        return execute_mutation(mutation, variables)
    except Exception as e:
        logging.debug(f'Error during the Fact Sheet update: {e}')
        raise e


def event_handler(req: dict) -> requests.Response|None:
    """Handles the request sent by the webhook. 
       It distinguishes between the relevant events and proceeds according to the provided logic.

    Args:
        req (dict): The webhook request as a dictionary to be handled.

    Returns:
        requests.Response|None: Response of the mutations.
    """       
    logging.info('Received webhook request')
    application_id = req.get('fromDetails').get('factSheetInfos').get('idAndRev').get('id')
    itc_id = req.get('toDetails').get('factSheetInfos').get('idAndRev').get('id')
    if req.get('type') == 'RelationCreatedEvent' or req.get('type') == 'RelationUpdatedEvent':
        remove_tag(application_id)
        return set_tag(application_id, parse_tags(get_tags(itc_id)))
    elif req.get('type') == 'RelationDeletedEvent':
        return remove_tag(application_id)
    else:
        return

Environment Variables

For the script to work correctly, configure the environment variables listed in the following table.

These variables are crucial for handling sensitive data such as access tokens and IDs, as well as for controlling the behavioral aspects of the script. By configuring these variables, you make it adaptable for various run scenarios.

Environment VariableData TypeDescription
LEANIX_API_TOKENStringThe authentication token used for accessing the API. To learn how to get an access token, see Authentication to SAP LeanIX Services.
LEANIX_SUBDOMAINStringThe subdomain of your SAP LeanIX workspace. You can copy the subdomain value from the workspace URL.
LINKED_TO_SERVER_IDStringThe ID of the Linked_to_Server tag.
LINKED_TO_DATABSE_IDStringThe ID of the Linked_to_Database tag.

Summary

In this tutorial, you learned how to automate the process of assigning and removing fact sheet tags on creating or removing relations using a Python script that is triggered by a webhook. You can modify the logic of the automation based on your requirements, using the provided code as an example.