Creating your first app

CloudBees SDM is a preview, with early access for select preview members. Product features and documentation are frequently updated. If you find an issue or have a suggestion, please contact CloudBees Support. Learn more about the preview program.
You must have CloudBees SDM administrative privileges to complete this tutorial.

In this tutorial, you will iteratively create a simple app for CloudBees SDM. The simple app is registered with CloudBees SDM and installed in your account, but adds no additional data. From there, you will layer on additional app features to demonstrate additional features of CloudBees SDM apps.

GraphQL playground

Throughout this tutorial, you will be using your app for creating and editing data types and data in CloudBees SDM. You can check your work using the GraphQL playground located at https://app.cloudbees.com/data/api/v1/a/${CLOUDBEES_ACCOUNT}/graphql/playground.

Thie URL contains your account name, so you must substitute it for ${CLOUDBEES_ACCOUNT}.

Getting a personal access token

The first step is to get a personal access token to be able to make secure calls to CloudBees SDM as you are developing your app. Along the way you will generate some keys and environment variables that will be necessary to complete the rest of this tutorial.

Your token is unique to you and should not be shared with anyone. Refer to Generating personal access tokens for instructions.

Set the token as an environment variable with the command below. You will use it throughout the rest of this guide to make calls to CloudBees SDM.

export SDM_API_TOKEN=<personal_access_token>

Setting your CloudBees Account

Set the environment variable below with your user profile:

export CLOUDBEES_ACCOUNT=acme

Setting the platform base URL

Set the environment variable below with the base URL for the CloudBees SDM platform:

export SDM_PLATFORM_API_BASEURL=https://devoptics.cloudbees.com

Creating an app manifest

To create an app manifest:

  1. Create an app-manifest.json file with the contents below.

  2. Replace the id with a random UUID.

{
  "id": "<random uuid>",
  "name": "SDM Example App",
  "url": "http://example.test",
  "private": true,
  "publicKeys": []
}

Registering your app

Run the curl command below to register your app with CloudBees SDM. This will make the app available for installation on your account. Depending on the visibility level set in your app manifest, the app will be available to other profiles (public) or only available to your profile (private).

curl --request POST "${SDM_PLATFORM_API_BASEURL}/platform/api/a/${CLOUDBEES_ACCOUNT}/app/registrations" \
--header "Content-Type: application/json" \
--header "Accepts: application/json" \
--header "Authorization: Bearer ${SDM_API_TOKEN}" \
--data @app-manifest.json

The response body returned from this call will contain the field id, which should match the id you set in the app-manifest.json file:

{
  "version": 2,
  "id": "<the random uuid value of the app's id>",
  "url": "http://example.test",
  "name": "SDM Example App",
  "iconUrl": null,
  "tags": [],
  "appVersion": null
}

Set the id field value as an environment variable because it is needed to do further work on the app:

export APP_ID=<the random uuid value of the app's id>
The app will not be usable in the CloudBees SDM UI until you’ve installed it, however it will appear among the available apps to install at apps.

That’s it! You’ve created your first app.

Installing your app

To install your app on your CloudBees SDM user profile:

  1. Go to https://app.cloudbees.com/apps.

  2. In the list of available apps, select your newly created app.

  3. Select Install.

You now have a fully functional app installed on your CloudBees SDM account. You will update this app in the sections below to add more functionality to it. Open the CloudBees SDM UI in your browser at apps to view your app on the Apps page.

Updating your app

The name given to the app in the section above is not very unique or descriptive. You should update the app to give it a more descriptive name. For the rest of these examples you will be building an app that sends data for a fictitious issue tracking program called Acme Issues. By simulating an example of a real category of software that mimics an important component of your DevOps toolchain you will be able to understand both how a CloudBees SDM app creates data and how that data fits into the larger data model available to users.

Since you are now creating an app to integrate the fictitious Acme Issues software with CloudBees SDM, update the name of the app to reflect that. Edit the app-manifest.json above with the updated name below.

{
  "id": "<random uuid>",
  "name": "Acme Issues",
  "url": "http://example.test",
  "publicKeys": []
}

Run the curl command below with the updated manifest to update your app. Afterward, you will see that the name of the app has been changed in the CloudBees SDM UI.

curl --request PUT "${SDM_PLATFORM_API_BASEURL}/platform/api/a/${CLOUDBEES_ACCOUNT}/app/registrations/${APP_ID}" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer ${SDM_API_TOKEN}" \
--data @app-manifest.json

Adding a data type to your app

One of the most important things that an app can do is introduce new data types for users who have installed the app on their CloudBees SDM account. In this section you will update the Acme Issues app to create an AcmeIssue data type and add some seed data.

Update the app-manifest.json as shown below, and run the same curl command to update the app again.

{
  "name": "Acme Issues",
  "url": "http://example.test",
  "private": true,
  "publicKeys": [],
  "extensions": {
    "com.cloudbees.sdm.DataType": {
      "acme_issues": {
        "schema": "type AcmeIssue @queryable(key: \"acme_issues\", singular: \"acmeIssue\", plural: \"acmeIssues\") @mutable { id: ID!, issueId: String, title: String, description: String }"
      }
    },
    "com.cloudbees.sdm.Data": {
      "acme_issue_1": {
        "type": "acme_issues",
        "data": {
          "issueId": "42",
          "title": "acme-1",
          "description": "acme-1-description"
        }
      }
    }
  }
}
curl --request PUT "${SDM_PLATFORM_API_BASEURL}/platform/api/a/${CLOUDBEES_ACCOUNT}/app/registrations/${APP_ID}" \
--header "Content-Type: application/json" \
--header "Accepts: application/json" \
--header "Authorization: Bearer ${SDM_API_TOKEN}" \
--data @app-manifest.json
The manifest now includes a type definition for AcmeIssues. This is the primary data type this app is going to write to CloudBees SDM. Although a real app could create an arbitrary number of data types based on its particular use case.
The AcmeIssues type does not have very many fields on it. You will update it later to add more fields to the type as you further develop the app.

After updating the app, open the GraphQL playground. AcmeIssue is now a valid type on your CloudBees SDM account. You can view details about it in the schema browser. Finally, you can run the query below without any errors, where you should see the seed entity added above, acme-issue-1.

Refer to CloudBees SDM GraphQL API for more information on data type schemas.

query {
  acmeIssues {
    nodes {
      id
      issueId
      title
      description
    }
  }
}

Adding data with your app

In this section you will begin adding data to CloudBees SDM for the AcmeIssue type added above.

Creating a secret key pair

Use the commands below to generate a key pair. Store the private key securely. You will provide the public key as part of your app manifest, and use the private key to authenticate requests from your app to send records to and records data from CloudBees SDM.

openssl ecparam -name prime256v1 -genkey -noout -out app-private-key.pem
openssl ec -in app-private-key.pem -pubout | sed -e ':a' -e 'N;$!ba' -e 's/\n/\\n/g' > app-public-key.pub

Update your manifest with the public key, as shown below.

The public key must be provided as one line, with the line breaks replaced with the ‘\n’ character for line breaks.
{
  "name": "Acme Issues",
  "url": "http://example.test",
  "private": true,
  "publicKeys": [
    "<contents of app-public-key.pub>"
  ],
  "extensions": {
    "com.cloudbees.sdm.DataType": {
      "acme_issues": {
        "schema": "type AcmeIssue @queryable(key: \"acme_issues\", singular: \"acmeIssue\", plural: \"acmeIssues\") @mutable { id: ID!, issueId: String, title: String, description: String }"
      }
    },
    "com.cloudbees.sdm.Data": {
      "acme_issue_1": {
        "type": "acme_issues",
        "data": {
          "issueId": "42",
          "title": "acme-1",
          "description": "acme-1-description"
        }
      }
    }
  }
}

Getting an app access token

An alternative workflow for this section involving refresh tokens obtained through the CloudBees SDM UI is beta now. Please contact a member of the CloudBees SDM development team if you would like to experiment with that workflow.

Next, you will obtain an app token to begin using our newly registered and installed app. In most cases this will be done programmatically, but we will show the steps to do it manually here as an example.

To do this, you will generate a signed JWT to authenticate as the app requesting a token.

You can use the JWT library or utility of your choice to generate the JWT, but an easy place to get started is using a web-based tool like jwt.io.

Use of jwt.io in the way shown below is only for tutorial purposes. Never let a production private key be used for anything other than programmatic app authentication in a secure environment.

Create a JWT with header:

{
  "alg": "ES256",
  "typ": "JWT"
}

and payload:

{
  "sub": "<your-app-id>",
  "jti": "<random-uuid>",
  "iat": 1592244466,
  "exp": 1592245066,
  "nbf": 1592244466
}

You can use a tool like this to generate the timestamps. iat is the "issued at" time, exp is the token "expiration time", and nbf is the "not valid before" time. Use the current time for iat and nbf and add 10 minutes for the exp time.

Paste your unmodified private key into the tool and add it to an environment variable:

export SIGNED_JWT=<signed jwt created above>

Obtain an access token by running:

curl --location --request POST "${SDM_PLATFORM_API_BASEURL}/platform/api/app/installations/${CLOUDBEES_ACCOUNT}/accessToken" \
--header "Authorization: Bearer ${SIGNED_JWT}"

Add this access token to an environment variable as shown:

export APP_ACCESS_TOKEN=<signed jwt created above>

Inspect the claims using the jwt.io debugger. Note that these app access tokens are valid for 1 hour, so don’t forget to obtain a new one if this tutorial extends past that.

Writing a record

Now you can use the GraphQL API manually, as your app would do programatically. Create a file containing a simple record to CloudBees SDM by running:

cat << EOF > add-issue.json
{
  "operationName": "addAcmeIssue",
  "variables": {
    "issueId": "42",
    "title": "acme-1",
    "description": "acme-1-description"
  },
  "query": "mutation addAcmeIssue($issueId: String, $title: String, $description: String) {\n  acmeIssue {\n    add(input: {issueId: $issueId, title: $title, description: $description}) {\n      acmeIssue {\n        id\n        issueId\n        title\n        description\n      }\n    }\n  }\n}\n"
}
EOF

For clarity, the query field in the JSON request above is reproduced below for clarity. It is a "typed" mutation that adds an entity to the AcmeIssue type.

mutation addAcmeIssue($issueId: String, $title: String, $description: String) {
  acmeIssue {
    add(
      input: { issueId: $issueId, title: $title, description: $description }
    ) {
      acmeIssue {
        id
        issueId
        title
        description
      }
    }
  }
}

The GraphQL API uses a POST to the same endpoint for all operations, /graphql.

This is different from the variables SDM_PLATFORM_API_BASEURL used above for registration and authentication services.

First, set an environment variable for the SDM base url:

export SDM_BASEURL=https://app.cloudbees.com
curl --location --request POST "${SDM_BASEURL}/data/api/v1/a/${CLOUDBEES_ACCOUNT}/graphql" \
--header "Authorization: Bearer ${APP_ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data @add-issue.json

Querying data with your app

Finally, we can use a GraphQL query to retrieve our new data:

cat << EOF > get-issue.json
{
  "operationName": null,
  "variables": {},
  "query": "{ acmeIssues { nodes { id issueId title description } } }"
}
EOF

Again, the query field in the JSON request above:

{
  acmeIssues {
    nodes {
      id
      issueId
      title
      description
    }
  }
}

You should see the entity you seeded CloudBees SDM with, along with the new entity you created.

At this point, your app should be able to ingest and retrieve data into CloudBees SDM via the GraphQL API as needed by your client application. Read more about the GraphQL API in general here and the CloudBees SDM API documentation here.

Remember that you’ll need to renew the access token hourly. Alternatively, contact a member of the CloudBees SDM development team regarding obtaining a refresh token for your app to obtain a longer-lived token, currently in beta.

Add a supporting datatype to your app

In the preceding section, you added a single data type to CloudBees SDM. Depending on your app’s use case, you may want to divide your data into multiple related types. In this next section, you will create AcmeIssueUser and AcmeIssueComment data types so the app can store user and issue comment data separately from issue data.

Update your app-manifest.json again using the example below creates the AcmeIssueUser and AcmeIssueComment data types. Using this update also creates a one-way relationships between AcmeIssues and both AcmeIssueUsers and AcmeIssueComment, populates the types with single AcmeIssueUser and AcmeIssueComment records, and updates previously-created issue records to relate to the new records.

In general, two-way relationships between queryable types are preferable because they allow for greater query flexibility. However, as AcmeIssueUser is not a queryable data type (meaning it cannot be the top-level data types on a query), a one-way relationship is sufficient here.

There is a one-to-one relationship on the creator relationship between AcmeIssues and AcmeIssueUser. An issue can only have one creator. There could be other fields that established a relationship between these two types (for example, an assignee field).

By contrast, there is a one-to-many relationship between AcmeIssues and AcmeIssueComments, since an issue could have an arbitrary number of comments attached to it. This is a two-way relationship. An AcmeIssue contains a list of related comments, and each AcmeIssueComments contains the id of the issue it relates to.

{
  "name": "Acme Issues",
  "url": "http://example.test",
  "private": true,
  "publicKeys": [
    "<contents of app-public-key.pub>"
  ],
  "extensions": {
    "com.cloudbees.sdm.DataType": {
      "acme_issues": {
        "schema": "type AcmeIssue @queryable(key: \"acme_issues\", singular: \"acmeIssue\", plural: \"acmeIssues\") @mutable { id: ID!, issueId: String, title: String, description: String, creatorId: String, creator: [AcmeIssueUser] @relationship(matches: [{source: \"creatorId\", target: \"userId\"}]), comments: [AcmeIssueComment] @relationship(matches: [{source: \"issueId\", target: \"issueId\"}]) }"
      },
      "acme_issue_users": {
        "schema": "type AcmeIssueUser { id: ID!, userId: String, name: String, email: String }"
      },
      "acme_issue_comments": {
        "schema": "type AcmeIssueComment { id: ID!, body: String, issueId: String, issue: AcmeIssue @relationship(type:\"AcmeIssue\", matches: [{source: \"issueId\", target: \"issueId\"}]) }"
      }
    },
    "com.cloudbees.sdm.Data": {
      "acme_issue_1": {
        "type": "acme_issues",
        "data": {
          "issueId": "42",
          "title": "acme-1",
          "description": "acme-1-description",
          "creatorId": "user1234"
        }
      },
      "acme_issue_user_1": {
        "type": "acme_issue_users",
        "data": {
          "userId": "user1234",
          "name": "Joe Smith",
          "email": "joesmitgh@acme.com"
        }
      },
      "acme_issue_comment_1": {
        "type": "acme_issue_comments",
        "data": {
          "body": "Issue body",
          "issueId": "42"
        }
      }
    }
  }
}

Note the @relationship on AcmeIssues, and that the type of that field is now AcmeIssueUser. The relationship between these two types now makes the following query possible.

{
  acmeIssues {
    nodes {
      id
      issueId
      title
      description
      creator {
        id
        name
        email
      }
    }
  }
}

Relating a data type to another type

In the examples above, you related AcmeIssue to AcmeIssueUser, two types you created, so you could create a query that returned these two related types. One of the strengths of CloudBees SDM is the ability for data types to query on relationships from separate but related sources such as a version control system, a build system, a security system, or any other part of a DevOps tool chain. In this section, you will connect AcmeIssues to Products, a type created by CloudBees SDM and included on all accounts for users to track their products’ development.

First, create a product in CloudBees SDM on which to relate your issues.

  1. Open the CloudBees SDM UI and go to the Products page at https://app.cloudbees.com/products/.

  2. Select Create product.

  3. Enter “Acme Issue Tracker” for the product name and select “Create”.

Now that you’ve created the Product, you can update the issues you are writing with the id of the product in order to reference that product.

Next, you need to update your manifest with a relationship on Products. An issue could conceivably belong to multiple products, so the products field is marked as a list by putting square brackets around the type, for example, [Product]. To update the issue you wrote to the system via your manifest, update your app-manifest.json with the changes below.

{
  "name": "Acme Issues",
  "url": "http://example.test",
  "private": true,
  "publicKeys": [
    "<contents of app-public-key.pub>"
  ],
  "extensions": {
    "com.cloudbees.sdm.DataType": {
      "acme_issues": {
        "schema": "type AcmeIssue @queryable(key: \"acme_issues\", singular: \"acmeIssue\", plural: \"acmeIssues\") @mutable { id: ID!, issueId: String, title: String, description: String, creatorId: String, creator: [AcmeIssueUser] @relationship(matches: [{source: \"creatorId\", target: \"userId\"}]), comments: [AcmeIssueComment] @relationship(matches: [{source: \"issueId\", target: \"issueId\"}]), products: [Product] @relationship }"
      },
      "acme_issue_users": {
        "schema": "type AcmeIssueUser { id: ID!, name: String, email: String, userId: String }"
      },
      "acme_issue_comments": {
        "schema": "type AcmeIssueComment { id: ID!, body: String, issueId: String, issue: AcmeIssue @relationship(type:\"AcmeIssue\", matches: [{source: \"issueId\", target: \"issueId\"}]) }"
      }
    },
    "com.cloudbees.sdm.Data": {
      "acme_issue_1": {
        "type": "acme_issues",
        "data": {
          "issueId": "42",
          "title": "acme-1",
          "description": "acme-1-description",
          "creatorId": "user1234",
          "products": [
            "<your product id>"
          ]
        }
      },
      "acme_issue_user_1": {
        "type": "acme_issue_users",
        "data": {
          "userId": "user1234",
          "name": "Joe Smith",
          "email": "joesmitgh@acme.com"
        }
      },
      "acme_issue_comment_1": {
        "type": "acme_issue_comments",
        "data": {
          "body": "Issue body",
          "issueId": "42"
        }
      }
    }
  }
}

Update the app with the manifest change and confirm the change in the GraphQL playground schema.

Make your type implement an interface

CloudBees SDM can aggregate and query data from tools that perform similar functions across customers' DevOps toolchains. A typical enterprise user may have teams using different issue tracking tools, and would like to query against all of those related types to look for trends and important insights across tools and teams. To enable this type of query, update your AcmeIssue data type so it implements the Issue interface.

Interfaces are the GraphQL mechanism for specifying abstract types. The Issue interface has been developed to generically represent issue data from any arbitrary issue tracking tools. The Jira Cloud App developed by Cloudbees is an example of an app that creates a type (JiraIssue) that implements the Issue interface. By updating AcmeIssue to also implement the Issue interface, a user can issue queries against the Issue type that will return both types of data.

To update your AcmeIssue type to implement the Issue interface, modify the AcmeIssue type definition to note that it implements Issue and then make sure that it contains all of the fields of the correct name and type to satisfy the Issue interface contract.

Your AcmeIssue type already contains almost all of the fields needed to satisfy the contract. There is one mismatch: the highest level text of an AcmeIssue is a title, while the Issue interface expects a similar text field called summary. This mismatch is easily resolved by adding another field to AcmeIssue that duplicates the value of title into a summary field. Update the AcmeIssue type with the new field as shown in the below example:

{
  "name": "Acme Issues",
  "url": "http://example.test",
  "private": true,
  "publicKeys": [
    "<contents of app-public-key.pub>"
  ],
  "extensions": {
    "com.cloudbees.sdm.DataType": {
      "acme_issues": {
        "schema": "type AcmeIssue implements Issue @queryable(key: \"acme_issues\", singular: \"acmeIssue\", plural: \"acmeIssues\") @mutable { id: ID!, issueId: String, title: String, summary: String @field(path:\"$.title\"), description: String, creatorId: String, creator: [AcmeIssueUser] @relationship(matches: [{source: \"creatorId\", target: \"userId\"}]), comments: [IssueComment] @relationship(matches: [{source: \"issueId\", target: \"issueId\"}]), product: Product @relationship }"
      },
      "acme_issue_users": {
        "schema": "type AcmeIssueUser { id: ID!, name: String, email: String, userId: String }"
      },
      "acme_issue_comments": {
        "schema": "type AcmeIssueComment implements IssueComment { id: ID!, body: String, issueId: String, issue: Issue @relationship(type:\"AcmeIssue\", matches: [{source: \"issueId\", target: \"issueId\"}]) }"
      }
    },
    "com.cloudbees.sdm.Data": {
      "acme_issue_1": {
        "type": "acme_issues",
        "data": {
          "issueId": "42",
          "title": "acme-1",
          "description": "acme-1-description",
          "creatorId": "user1234",
          "products": [
            "<your product id>"
          ]
        }
      },
      "acme_issue_user_1": {
        "type": "acme_issue_users",
        "data": {
          "userId": "user1234",
          "name": "Joe Smith",
          "email": "joesmitgh@acme.com"
        }
      },
      "acme_issue_comment_1": {
        "type": "acme_issue_comments",
        "data": {
          "body": "Issue body",
          "issueId": "42"
        }
      }
    }
  }
}

This change creates a duplicate field summary that returns a value identical to that of title. This additive change to your schema extends the Issue interface without requiring any changes to the process writing this data or to any already-ingested AcmeIssue records.

This change also updates AcmeIssueComment to implement the IssueComment interface. The relationship between AcmeIssue and AcmeIssueComment is now based on the Issue and IssueComment interface types to allow more flexible queries when working with multiple Issue types.