Using the Swift OpenAPI Generator for the Jamf Pro API

For the past several months I have been learning Swift and SwiftUI and have finally reached the point where I want to build a few small, but capable apps to start putting together my new skills. One of these ideas requires interacting with the Jamf Pro API. I had not done much with network code at this point, but I remembered a session from WWDC 2023 that I was very interested in: “Meet Swift OpenAPI Generator.”

For the past several months I have been learning Swift and SwiftUI and have finally reached the point where I want to build a few small, but capable apps to start putting together my new skills. One of these ideas requires interacting with the Jamf Pro API. I had not done much with network code at this point, but I remembered a session from WWDC 2023 that I was very interested in: Meet Swift OpenAPI Generator.

These Swift packages create API clients and code models from OpenAPI documents. This is an approach known as “spec-driven development.” While it’s not a lot of work to get a few API requests written using URLSession, there’s a lot more effort that goes into the interfaces for those operations, and even more work to write the models that the responses become.

My approach to learning Swift has also been to focus on where we are going as platform engineers, and using OpenAPI to drive client code feels like the most correct approach.

There’s a bit of process to get through.

  • Xcode Setup: Install the Swift OpenAPI packages required and configure the build settings.
  • OpenAPI Doc: Copy the Jamf Pro OpenAPI document, update it, and configure the generator.
  • Auth Middleware: Requests need to be authenticated with an access token. This is handled by creating a middleware that will fetch and insert tokens into client requests.
  • Client Code: The generated client needs to be configured so that it can be used in the main application.

It is a bit of work up front, but I’ll showcase the benefits with a small example app, and how to extend these resources further.

This entire example project is now available on my GitHub.

The OpenAPI Generator

You will first need to install three packages using the Swift Package Manager. There is no central repository for packages with Swift as you might expect with languages like Python and Javascript. Swift packages are shared through git repositories. From the menu bar, select File > Add Package Dependencies… This brings up Xcode’s interface for the package manager.

There is a default collection of Apple Swift Packages in the sidebar. The OpenAPI packages are not included in this. You will need to copy and paste the GitHub URLs for three required packages into the search bar in the upper-right. I have provided them below:

When viewing a package you will see the Dependency Rule defaults to Up to Next Major Version. Swift packages follow semantic versioning. Each Swift OpenAPI package is at major version 1 at the time of this post. This dependency rule will pull in all updates for those packages up until they move to the next major version (2).

This is a sane default for your dependencies and I recommend keeping it.

For each package, paste in the URL to the search and click Add Package. There will be a pop-up window asking you to add package products to targets in your project.

When you install the Plugin there will be two products listed. Do not add them (leave at None).

When you install the Runtime and Transport they contain one product each and both should be added to your project’s target.

In Xcode’s sidebar a new Package Dependencies section will appear below your project files. It will list every package installed into your project. You’ll notice that there are a lot more than the three you just added. These are sub-dependencies the OpenAPI packages rely on.

Now the OpenAPI plugin must be added to the project target’s build phase. Navigate back to the project settingstarget. Go to the Build Phases section and expand Run Build Tool Plug-ins. Click the + button. In the pop-up window you will see under the swift-openapi-generator package a OpenAPIGenerator item with a bullseye icon. Select this and click Add.

Expand the Link Binary With Libraries section below and you should see both OpenAPIRuntime and OpenAPIURLSession already listed. This was done when you added the package products to the target.

The Xcode project is now setup and ready for the API client.

Jamf Pro OpenAPI Doc

This post will only cover the Pro API and not the Classic API.

Any Jamf Pro server has the OpenAPI document for its version available at the following URL:

  • https://<instance-name>.jamfcloud.com/api/schema/

As of version 11.7.1, this JSON file is 1.5 MB in size and over 45,000 lines long. If you tried to build the client using this raw file you’re going to encounter errors. and then encounter new errors as you start to patch them over.

Some of these errors are due to the Swift OpenAPI Generator not supporting an option that was defined, but most are errors when Jamf generated the document.

In the Appendix of this blog post I will include guidance for how to correct the errors I encountered in the 11.7.1 Pro OpenAPI doc.

There are still two remaining issues with Jamf’s OpenAPI doc to address before using it to generate client code.

For the overwhelming majority of paths there is no operationId. The OpenAPI generator would use this to create the method name in the client. Without this, it will autogenerate names that look like this: get_sol_v1_sol_computers_hyphen_inventory. That is the default generated name for GET /v1/computers-inventory. It makes for hard to read and hard to discover code.

The other issue is you are generating client code for hundreds of API endpoints that you will not use, and likely would never use. The generated Client file for the full OpenAPI doc was 57,000+ lines long, and the generated Types file a staggering 155,500+ lines. ~10 MB of unused Swift code.

The missing operationId properties can be manually addressed. For the APIs you intend to use you would add them into the path objects like so:

{
...
"/v1/computers-inventory" : {
  "get" : {
    "operationId": "ComputersInventoryGetV1",
    ...
}

The naming scheme I recommend is {Path}{Method}{Version} in capital-case without spaces, underscores, or hyphens as shown above. This naming scheme makes it easy to see all of the available methods and versions of an API when Xcode shows autocompletion options as you type.

The challenge of the large number of APIs you don’t intend to use is addressed by properly configuring the OpenAPI generator.

I have also tried creating a minimal OpenAPI document using openapi-extract to pull out the paths and schemas I wanted. I could then manually merge them into a single file after. This is, however, a very manual process, and it is a Javascript command line tool with very little instruction on how to setup.

Plugin Configuration

The next file you are going to add will be openapi-generator-config.yaml with the following contents:

generate:
  - client
  - types
filter:
  paths:
    - /v1/computers-inventory
    - /v1/jamf-pro-version

This file will instruct the generator to create client code from the OpenAPI doc and Swift types from the schemas. The types are critically important. These will be Swift structs returned by the client operations with properties that can be accessed through dot notation. Xcode’s autocomplete will show all of the possible values as you type making interacting with the response data simple and easy.

The third option for generate is server. You can create all of the stubs for the API itself using a web framework like Vapor. This will be worth exploring another day.

The filter property will only generate code for items that match the criteria. In the example above I am only asking for two APIs. At any time you can add additional paths to expand the capabilities of the client code.

Less code is the best code.

The First Build

Without adding a single Swift file you can now attempt the first build.

Go to the menu bar and select Product > Build or press ⌘ + B on your keyboard. For the very first time you use the plugin a dialog will appear asking for confirmation that you trust it. To continue, click Trust & Enable All.

If you encounter build errors at this step you will need to investigate the Issue and Reports navigators to find the cause. If there are errors related to the OpenAPI doc jump to the bottom of the blog in the Appendix where I have a section on how to address errors I encountered.

Sometimes your changes to the OpenAPI doc won’t reflect correctly in your code when you rebuild. You can clean the build caches by pressing ⌘ + ⌥ + ⇧ + K.

The Client Code

The generated Client, Operations, and Components objects are now available to import.

Were this API unauthenticated the Client could be used directly, but Jamf Pro requires authentication with an access token. The example in this post is going to focus on authentication using client credentials flow using a Jamf Pro API Client.

Press ⌘ + N and create a new Swift file in your project called JamfAPIClient.swift.

Add these imports:

// JamfAPIClient.swift

import Foundation
import HTTPTypes
import OpenAPIRuntime
import OpenAPIURLSession

A wrapper struct will be needed to handle all of the configuration and token management boilerplate code. This will become the main interface for the Jamf Pro API instead of using the Client directly.

struct JamfProAPIClient {
    let api: Client

    let clientId: String
    private let clientSecret: String

    init(hostname: String, clientID: String, clientSecret: String) {
        self.clientId = clientID
        self.clientSecret = clientSecret
        self.api = Client(
            serverURL: URL(string: "https://\(hostname):443/api")!,
            configuration: Configuration(dateTranscoder: .iso8601WithFractionalSeconds),
            transport: URLSessionTransport()
        )
    }
}

Where the inner Client is being instantiated a URL concatenated together from the passed hostname. The URLSessionTransport is the one installed with swift-openapi-urlsession package.

The Configuration being passed sets a different date transcoder than the default. Date strings in Jamf Pro contain fractional seconds*. This needs to be set or else decoding errors will occur for timestamps that include them.

Configuration(dateTranscoder: .iso8601WithFractionalSeconds)
  • See the Appendix for issues I encountered with ISO8601 date string decoding.

With all the work for setup now handled by the wrapper, here is the new client in action:

// Example use
let client = JamfProAPIClient(
    hostname: "dummy.jamfcloud.com",
    clientID: "43fd12fc...",
    clientSecret: "Fn96LFQP..."
)
print(client.clientId) // Inspect and identify clients
let jamfProVersion = try await client.api.JamfProVersionGetV1()

Adding Authentication

The code thus far does not yet include authentication. To do this a middleware must be created that handles obtaining access tokens using the client credentials and injecting that token into the requests. It should also cache the token, reusing it for its lifetime, and refresh the token in a way that is thread-safe.

The ClientMiddleware protocol allows custom code for inspecting and modifying requests before they are sent to the transport. Multiple middlewares can be passed to a client to handle different operations like logging, header manipulation, and authentication.

This is the minimal code to start:

struct APIClientMiddleware: ClientMiddleware {
    // Store the access token here
    func intercept(
        _ request: HTTPRequest,
        body: HTTPBody?,
        baseURL: URL,
        operationID: String,
        next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
    ) async throws -> (HTTPResponse, HTTPBody?) {
        var request = request
        // Retrieve and inject the access token here
        return try await next(request, body, baseURL)
    }
}

Because this is conforming to a protocol, Xcode can autocomplete the entire signature for intercept for you as you type.

The comments identify where the code for the token needs to be added. Before writing the code that calls POST /api/oauth/token there needs to be an object to store the token data from the response and evaluate if it is still valid.

This struct is written to be instantiated from the JSON response for client credentials authentication:

struct AccessToken: Codable {
    let access_token: String
    let expires_in: Int
    let expiration_date: Date

    var isExpired: Bool {
        return expiration_date < Date()
    }

    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.access_token = try container.decode(String.self, forKey: .access_token)
        self.expires_in = try container.decode(Int.self, forKey: .expires_in)
        self.expiration_date = Date().addingTimeInterval(Double(expires_in))
    }
}

isExpired is a computed property that will return true if the calculated expiration exceeds the current time when it is called.

Because both the client and the middleware are asynchronous there is a risk of a race condition where multiple threads attempt to refresh the token at the same time. Implementing the AccessTokenManager as an Actor will help address this.

Actors are like classes, but access to their properties and methods are serialized. If multiple threads performing requests all trigger the creation of a new token only one needs to occur and the rest will queue until they retrieve the newly cached token.

actor AccessTokenManager {
    private let tokenURL: URL
    private let clientId: String
    private let clientSecret: String

    var currentToken: AccessToken?
    var activeTokenTask: Task<AccessToken, Error>?

    init(tokenURL: URL, clientId: String, clientSecret: String) {
        self.tokenURL = tokenURL
        self.clientId = clientId
        self.clientSecret = clientSecret
    }
}

The AccessTokenManager will take in the URL to request tokens from, the client ID, and client secret. Internally, it will store the current access token using the struct from above, and a Task. The task will be used to control concurrency on retrieving tokens.

The token manager requires its own network code apart from the API client. This is a custom error that will be throw if any part of the token requests fail:

enum JamfProAPIClientError: Error {
    case AuthError(String)
}

The method to request access tokens will look similar to many other examples of URLSession you may have seen. It is also a look at the verbose code we want to avoid having to write. Every API would require data model code (the AccessToken struct above), and HTTP request code.

This code follows Jamf’s recipe for client credentials auth on the developer portal.

func requestAccessToken() async throws -> AccessToken {
    var request = URLRequest(url: tokenURL)

    request.httpMethod = "POST"

    request.setValue("application/json", forHTTPHeaderField: "Accept")
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

    var body = URLComponents()
    body.queryItems = [
        URLQueryItem(name: "grant_type", value: "client_credentials"),
        URLQueryItem(name: "client_id", value: clientId),
        URLQueryItem(name: "client_secret", value: clientSecret)
    ]
    request.httpBody = body.query?.data(using: .utf8)

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let httpResponse = response as? HTTPURLResponse else {
        throw JamfProAPIClientError.AuthError("Token request failed with response: \(response)")
    }

    if httpResponse.statusCode != 200 {
        throw JamfProAPIClientError.AuthError("Token request failed with status code: \(httpResponse.statusCode)")
    }

    guard let newAccessToken = try? JSONDecoder().decode(AccessToken.self, from: data) else {
        throw JamfProAPIClientError.AuthError("Failed to decode access token: \(data)")
    }

    return newAccessToken
}

Now the interface for thread-safe token requests. getAccessToken will be called by the middleware to return the current valid token that has been cached.

func getAccessToken() async throws -> AccessToken {
    if let activeTokenTask {
        return try await activeTokenTask.value
    }
    
    if let currentToken, currentToken.isExpired {
        return currentToken
    }
    
    activeTokenTask = Task {
        try await requestAccessToken()
    }
    
    guard let newToken = try await activeTokenTask?.value else {
        throw JamfProAPIClientError.AuthError("Failed to return access token")
    }
    currentToken = newToken
    activeTokenTask = nil

    return newToken
}

Here is a breakdown of the logic above:

  1. Check if there is an active task. If there is, another thread is requesting a new access token and this one will wait for it to complete and return the value.
  2. Check if there is a current token and that it is not expired. If the token exists and is valid it will be returned.
  3. If neither of the above conditions are met a new token will be requested and returned.

Here is the complete AccessTokenManager:

actor AccessTokenManager {
    private let tokenURL: URL
    private let clientId: String
    private let clientSecret: String

    var currentToken: AccessToken?
    var activeTokenTask: Task<AccessToken, Error>?

    init(tokenURL: URL, clientId: String, clientSecret: String) {
        self.tokenURL = tokenURL
        self.clientId = clientId
        self.clientSecret = clientSecret
    }

    func getAccessToken() async throws -> AccessToken {
        if let activeTokenTask {
            return try await activeTokenTask.value
        }
        
        if let currentToken, currentToken.isExpired {
            return currentToken
        }
        
        activeTokenTask = Task {
            try await requestAccessToken()
        }
        
        guard let newToken = try await activeTokenTask?.value else {
            throw JamfProAPIClientError.AuthError("Failed to return access token")
        }
        currentToken = newToken
        activeTokenTask = nil

        return newToken
    }

    func requestAccessToken() async throws -> AccessToken {
        var request = URLRequest(url: tokenURL)

        request.httpMethod = "POST"

        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

        var body = URLComponents()
        body.queryItems = [
            URLQueryItem(name: "grant_type", value: "client_credentials"),
            URLQueryItem(name: "client_id", value: clientId),
            URLQueryItem(name: "client_secret", value: clientSecret)
        ]
        request.httpBody = body.query?.data(using: .utf8)

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw JamfProAPIClientError.AuthError("Token request failed with response: \(response)")
        }

        if httpResponse.statusCode != 200 {
            throw JamfProAPIClientError.AuthError("Token request failed with status code: \(httpResponse.statusCode)")
        }

        guard let newAccessToken = try? JSONDecoder().decode(AccessToken.self, from: data) else {
            throw JamfProAPIClientError.AuthError("Failed to decode access token: \(data)")
        }

        return newAccessToken
    }
}

And here it is integrated back into the APIClientMiddleware:

struct APIClientMiddleware: ClientMiddleware {
    let accessTokenManager: AccessTokenManager

    init(accessTokenManager: AccessTokenManager) {
        self.accessTokenManager = accessTokenManager
    }

    func intercept(
        _ request: HTTPRequest,
        body: HTTPBody?,
        baseURL: URL,
        operationID: String,
        next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
    ) async throws -> (HTTPResponse, HTTPBody?) {
        guard let accessToken = try? await accessTokenManager.getAccessToken() else {
            throw JamfProAPIClientError.AuthError("Failed to fetch access token")
        }

        var request = request
        request.headerFields[.authorization] = "Bearer \(accessToken.access_token)"

        return try await next(request, body, baseURL)
    }
}

The complete middleware solution can now be passed to the client code:

struct JamfProAPIClient {
    public let api: Client

    let clientId: String
    private let clientSecret: String

    init(hostname: String, clientID: String, clientSecret: String) {
        self.clientId = clientID
        self.clientSecret = clientSecret
        self.api = Client(
            serverURL: URL(string: "https://\(hostname):443/api")!,
            configuration: Configuration(dateTranscoder: .iso8601WithFractionalSeconds),
            transport: URLSessionTransport(),
            middlewares: [
                APIClientMiddleware(
                    accessTokenManager: AccessTokenManager(
                        tokenURL: URL(string: "https://\(hostname):443/api/oauth/token")!,
                        clientId: clientID,
                        clientSecret: clientSecret
                    )
                )
            ]
        )
    }
}

Using the Client

Now that all of the work for setting up and creating the Jamf Pro API client is done it is time to put it to use and demonstrate how powerful the Swift OpenAPI Generator is.

Below is a small SwiftUI app using the JamfProAPIClient above to render a list of computers displaying their names, the management ID, and the assigned user. It also displays the total number of computers at the top.

An iPhone simulator screenshot showing a list of Jamf Pro computer entries

Here is the complete code:

// ContentView.swift

import SwiftUI

struct ContentView: View {
    @State private var client = JamfProAPIClient(
        hostname: "dummy.jamfcloud.com",
        clientID: "43fd12fc...",
        clientSecret: "Fn96LFQP..."
    )

    @State private var computerSearchResults: Components.Schemas.ComputerInventorySearchResults?

    var body: some View {
        List {
            Section {
                HStack {
                    Text("Total computers:")
                        .font(.headline)
                    Spacer()
                    Text(String(computerSearchResults?.totalCount ?? 0))
                }
            }

            Section {
                if let computerResults = computerSearchResults?.results {
                    ForEach(computerResults, id: \.self) { computer in
                        VStack(alignment: .leading) {
                            Text("\(computer.general?.name ?? "Unknown") | \(computer.id ?? "Unknown")")
                                .font(.headline)
                            Text(computer.general?.managementId ?? "Unknown")
                                .font(.caption)
                                .textSelection(.enabled)
                            HStack {
                                Text("Assigned User:")
                                Text(computer.userAndLocation?.username ?? "Unkown")
                            }
                        }
                    }
                }
            }
        }
        .task {
            do {
                let response = try await client.api.ComputersInventoryGetV1(
                    .init(
                        query: .init(
                            section: [.GENERAL, .USER_AND_LOCATION],
                            page: 0,
                            page_hyphen_size: 1000
                        )
                    )
                )
                computerSearchResults = try response.ok.body.json
            } catch {
                print(error.localizedDescription)
            }
        }
    }
}

The client is instantiated as a property of the view struct. The other property is to hold the response of the GET /v1/computers-inventory API. Components contains generated types from the OpenAPI doc. It follows the same structure and names as the components object in the doc.

@State private var computerSearchResults: Components.Schemas.ComputerInventorySearchResults?

The view will automatically load data into computerSearchResults at launch. The task modifier contains the client call to ComputersInventoryGetV1.

let response = try await client.api.ComputersInventoryGetV1(
    .init(
        query: .init(
            section: [.GENERAL, .USER_AND_LOCATION],
            page: 0,
            page_hyphen_size: 1000
        )
    )
)
computerSearchResults = try response.ok.body.json

For the sake simplicity this code is embedded with the .task {}. A better, more organized approach would be to move this its own function and call that.

This is a very elegant interface to what is a fairly complex API. GET /v1/computers-inventory uses query string parameters to control and filter the returned computers. The sections are parts of the computer object to include. In code it takes an array ComputerSection enums that have all of the valid values because it was generated from the OpenAPI definition.

Imagine having to code all of this by hand.

response.ok.body.json returns the ComputerInventorySearchResults type. Once this happens the SwiftUI code will automatically render the list.

if let computerResults = computerSearchResults?.results {
    ForEach(computerResults, id: \.self) { computer in
        VStack(alignment: .leading) {
            Text("\(computer.general?.name ?? "Unknown") | \(computer.id ?? "Unknown")")
                .font(.headline)
            Text(computer.general?.managementId ?? "Unknown")
                .font(.caption)
                .textSelection(.enabled)
            HStack {
                Text("Assigned User:")
                Text(computer.userAndLocation?.username ?? "Unkown")
            }
        }
    }
}

The results property is an array of ComputerInventory types. If the results have been loaded, the ForEach loop will display a row for every computer. All of the information that is being displayed is being accessed through dot notation on the record.

Because most properties in the Jamf Pro OpenAPI schemas are optional (meaning it may be null / nil) nil coalescing using ?? is needed to provide a default value if it cannot be read.

Note that computerResults does not conform to Identifiable. This appears to be the case for any array in the generated types, and this would be expected as the generator cannot guarantee that the contained items are unique.

Extending the Client

Now that you have seen how easy it is to use the Jamf Pro API after creating a client using the OpenAPI generator, let’s see how easy it is to extend this foundation with new capabilities.

First, new APIs can be included with the client by adding them to the filter of the openapi-generator-config.yaml.

generate:
  - client
  - types
filter:
  paths:
    - /v1/computers-inventory
    - /v1/computers-inventory-detail/{id}
    - /v1/jamf-pro-version

Now in code a single, full computer record can be requested by its ID:

let response = try await client.api.ComputersInventoryDetailByIdGetV1(
    .init(
        path: .init(
            id: "117"
        )
    )
)

You may be wondering about the shorthand inits that are happening, and why there are so many of them. It may make more sense if you see the full names for the same method call:

let response = try await client.api.ComputersInventoryDetailByIdGetV1(
    Operations.ComputersInventoryDetailByIdGetV1.Input.init(
        path: Operations.ComputersInventoryDetailByIdGetV1.Input.Path.init(
            id: "117")
    )
)

Every API request’s input and response are defined as types, and those objects define all of the possible options as types. GET /v1/computers-inventory-details/{id} takes a path argument as a string – the computer ID. When writing the request using the OpenAPI client each of these types must be instantiated. Swift provides shorthand syntax to spare you all of that verbose typing.

Go back and take another look at ComputersInventoryGetV1 with this newfound knowledge.

Extending OpenAPI

Missing or undocumented APIs can also be added to the OpenAPI doc and be made available in the client. The POST /api/oauth/token endpoint used by the AccessTokenManager is not documented. While all of the code in the token manager is available, it would be more convenient to have a method to request arbitrary tokens as needed.

Here is the OpenAPI JSON for the token endpoint:

{
  "paths": {
    "/oauth/token": {
      "post": {
        "operationId": "AccessTokenRequest",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "client_id",
                  "client_secret",
                  "grant_type"
                ],
                "properties": {
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string"
                  },
                  "grant_type": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string"
                    },
                    "expires_in": {
                      "type": "integer"
                    },
                    "scope": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

This can be added to the top of the paths object in the OpenAPI doc. Once added, trigger a new build and the API method will be available. Scroll back to the AccessTokenManager to remember the code required for that single URLSession request.

Now compare to the new AccessTokenRequest method:

let response = try await client.api.AccessTokenRequest(
    body: .urlEncodedForm(
        .init(
            client_id: clientId,
            client_secret: clientSecret,
            grant_type: "client_credentials"
        )
    )
)
return try response.ok.body.json.access_token

All our code should be so pleasant.

Helper Methods

The earlier example usage of ComputersInventoryGetV1 set the page size to 100, but the total count for all computers was 101. New APIs in the Jamf Pro API are paginated and in larger datasets repeat calls are required to obtain the full result.

Below is a method I wrote and added to the JamfProAPIClient that wraps ComputersInventoryGetV1, detects if there are more computers reported for the total than have been returned, and loops requests until it has exhausted all possible pages of the original query.

func ComputerInventoryGetV1AllPages(
    query: Operations.ComputersInventoryGetV1.Input.Query = .init(page: 0, page_hyphen_size: 2000)
) async throws -> Components.Schemas.ComputerInventorySearchResults {
    var currentPage = max(query.page ?? 0 - 1, -1)
    var computerResults = Components.Schemas.ComputerInventorySearchResults(totalCount: 1, results: [])

    while computerResults.results!.count < computerResults.totalCount! {
        currentPage += 1

        let nextPage = try await api.ComputersInventoryGetV1(
            .init(
                query: .init(
                    section: query.section,
                    page: currentPage,
                    page_hyphen_size: query.page_hyphen_size,
                    sort: query.sort,
                    filter: query.filter
                )
            )
        )

        let nextPageResults = try nextPage.ok.body.json

        computerResults.totalCount = nextPageResults.totalCount ?? 0

        if nextPageResults.results!.count == 0 {
            return computerResults
        } else {
            computerResults.results?.append(contentsOf: nextPageResults.results!)
        }
    }

    return computerResults
}

There are a lot of force unwraps ! in this code for the totalCount and results of the inventory response. This is intentional: those values are guaranteed to exist. Neither of these can actually ever be nil/null. The API will return a 0 and an empty array if there aren’t any results.

Most of the Pro API schemas do not list required properties. This defines which properties are not optional and must be present. This applies to both writes and reads. On the ComputerInventory schema you’ll find that the id, another known guaranteed property, is not marked as required and thus becomes an optional in the generated struct.

The task code that automatically loads the list of computers can now call this and be guaranteed to fetch the entire inventory for display.

computerSearchResults = try await client.ComputerInventoryGetV1AllPages(
    query: .init(
        section: [.GENERAL, .USER_AND_LOCATION],
        page_hyphen_size: 30
    )
)

Note that for this helper method I reused Operations.ComputersInventoryGetV1.Input.Query so Xcode would provide the same autocompletion and help text as the lower-level non-paginated call.

Schema Extensions

Earlier in the example app code I explained that by default the generated types from the OpenAPI generator do not conform to Identifiable. The line that loops over the results to display them requires setting the id argument:

ForEach(computerResults, id: \.self) { computer in
    // View code here
}

My friend Nindi pointed out that this can be fixed by using an Extension. The ComputerInventory types all have id attributes and will automatically fulfill the requirements for Identifiable (as will any other Jamf schema that includes an id).

This is all the code that is needed to add the protocol:

//  Extensions+Components.Schemas.swift

extension Components.Schemas.ComputerInventory: Identifiable {}

Putting these in their own file is another best practice for code organization.

Now the ForEach loop can be simplified:

ForEach(computerResults) { computer in
    // View code here
}

What’s Next?

Getting all of this working has been great “aha!” moment.

Even as I wrote this post I was going back and further simplifying and improving the original example code I had intended to share. Next I’ll be taking all this work and applying back to another project I intend to bring to the App Store. I’ll be updating this post with any new learnings from that.

If you are learning or using Swift and are trying out the steps in the guide for your own projects drop a comment and let me know!

Appendix

Fixing the OpenAPI Doc

These are the errors I encountered trying to build a client from the 11.7.1 Pro API OpenAPI doc and how I remediated them. Errors during the build will appear in the Reports navigator. The most recent report will be at the top. The Build has a hammer icon, and there should also be a yellow warning or red error symbol to the right. Select this to view those logs.

  • Invalid content type string...
    There were two .../history APIs where Jamf generated an invalid content-type label for the 200 responses. Instead of documenting two types of responses they were concatenated together as text/csv,application/json. Edit these to just one of the types to clear the error.
  • Feature "Cookie params" is not supported...
    The generator does not support cookie parameters. The PATCH /v2/account-preferences API has JSESSIONID as in the cookie. Delete this object.
  • warning: A property name only appears in the required list, but not in the properties map...
    An API lists a required field that doesn’t exist. There will be multiples of this and you will need to inspect the error message to get the location and the value. For example, context: foundIn=Components.Schemas.CloudLdapServerUpdate (#/components/schemas/CloudLdapServerUpdate)/providerName shows the schema at issue is CloudLdapServerUpdate and the property that’s required but does not exist is providerName.
  • Invalid discriminator.mapping value... must be an internal JSON reference.
    In the MdmCommandRequest the discriminator mapping still includes external file references. Those schemas all exist within the OpenAPI document. Remove all of the *.yaml prefixes.

Date Decoding Errors

Between two different Jamf Pro instances while testing I have encountered this issue in my console logs when returning device data:

Client error - cause description: 'Unknown', underlying error: DecodingError: dataCorrupted - at : Expected date string to be ISO8601-formatted.

I suspect this is an issue due to old, inconsistent formats for dates between the two. In one of the Jamf Pro instances a record had timestamps with and without the fractional seconds.

Here is the date transcoder I am using in this post’s client configuration:

configuration: Configuration(dateTranscoder: .iso8601WithFractionalSeconds)

That sets up an ISO8601DateFormatter with the following options:

ISO8601DateTranscoder(options: [.withInternetDateTime, .withFractionalSeconds])

When .withFractionalSeconds is set it requires that all timestamps contain fractional seconds. Responses with mixed types of ISO8601 formats will throw the decoding error. To work around this, I wrote my own date transcoder based on the generator’s that will attempt a factional decoding first, and fall back to non-fractional.

struct CustomDateTranscoder: DateTranscoder {
    private let lock: NSLock

    public init() {
        lock = NSLock()
    }

    public func encode(_ date: Date) throws -> String {
        lock.lock()
        defer { lock.unlock() }
        return Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)
    }

    public func decode(_ dateString: String) throws -> Date {
        lock.lock()
        defer { lock.unlock() }
        do {
            return try Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(dateString)
        } catch {
            do {
                return try Date.ISO8601FormatStyle().parse(dateString)
            } catch {
                throw DecodingError.dataCorrupted(
                    .init(codingPath: [], debugDescription: "Expected date string '\(dateString)' to be ISO8601-formatted.")
                )
            }
        }
    }
}

This is a drop-in replacement for the builtin date transcoder:

configuration: Configuration(dateTranscoder: CustomDateTranscoder())

This custom date transcoder is also Swift 6 compliant. In Xcode 16 if you try to encode/decode using ISO8601DateFormatter (as the ISO8601DateTranscoder does) there will be a warning that it does not conform to Sendable.

Flexli Workflows Preview

Last month I wrote a post that briefly discussed Jamf Routines, and then detailed using AWS Step Functions and my project Flexli Engine as alternatives for building API workflows.

I am now running a preview of the service through June 30th to get its APIs in the hands of real users, implement feedback, and vet the potential as a service offering.

Flexli was designed from the ground up to enable highly customized API automation that could be templated and shared within IT communities. Create connectors to nearly any API and use those connectors to build nearly any kind of workflow you needed.

While Flexli Engine’s capabilities have broad application, the preview will be primarily focusing on MDM administrator and client engineer use cases. This is the scope of the preview:

  • Full access to the engine APIs for creating connectors and workflows within your tenant.
  • Assistance creating connectors for the APIs you want to integrate with and workflows for your use cases.
  • Weekly open office hours calls for collaboration, discussion, and live demos.
  • Any work on bug fixes or features as a part of the preview will be committed back to the public repository.

Most active discussion and announcements will be held in the Mac Admins Slack in the #flexli-interest-channel.

Flexli Engine’s user guide and API docs are linked below. Examples of connectors and workflows are also included in the GitHub repository for your reference. If you are interested in being a preview user please email me at [email protected] or message me on the Mac Admins Slack.

Low-Code Alternatives to Jamf Routines

The recently introduced Jamf Routines is a no-code automation solution leveraging Jamf Pro’s API and webhooks. This post introduces the new service, discusses some of its shortcomings, and explores two low-code alternatives that allow more customization and control.

Jamf Routines

Jamf recently aired their April 2024 event and dropped a new service (in beta) for business plan customers: Jamf Routines. A no-code automation solution that leverages Jamf Pro’s API clients and webhooks. It’s a curated experience where Jamf is delivering these automations in the form of templates that you fill out a form for and then deploy.

Automation solutions like this are a great tool to have. They run independently from the product so new functionality or features aren’t tied to the product’s releases (specifically, Jamf Pro’s releases). You aren’t running any code of your own, so the operational burden on the Jamf admin is low. And the cherry on top is they’re very easy to start using!

Jamf Routines’ interface is simple and easy to understand. You’re presented a list of your created routines on the main page. Click + Add Routine to be taken to the template picker shown above. Clicking Configure will take you to a form to create your first integration.

It is on this page you can add Jamf Pro servers to use with your routines. You can later view and delete them from the settings page, but you can only add them here. Adding a server requires the URL and an administrator’s login credentials. Jamf Routines doesn’t store these credentials. Instead, it will use your access to create an API role and client in Jamf Pro that it will then store for all future interactions.

The created role grants broad read access to Jamf Pro resources. In addition to these, the API Client has full permissions to manage all webhooks as well as API roles and clients – creating, modifying, and deleting them.

Once the server has been added the form will then populate any contextual options by querying the Jamf Pro API (such as group names inside the pickers). Some routines are event-driven using webhooks. Saving one of these routines will create the webhoook resource in Jamf Pro and it will start working immediately.

Jamf Routines’ use of webhooks here is somewhat inefficient. Each routine will always have its own unique webhook instead of managing a single webhook of that type and then using it across all routines that use it.

This won’t be an issue now with the limited number of routines available, but as this library grows the impact on the Jamf Pro servers could be more noticeable if you’re running ten different automations off an event like MobileDeviceCheckIn which would mean each check-in results in ten webhooks sending.

Shortcomings of Routines

Jamf is likely to release new Routines over time, but this service is only providing curated workflows from Jamf’s staff. It’s not clear yet how these workflows are selected for publishing, or what the pipeline to get a customer’s request for some kind of automation into Routines is.

There’s no customization of these routines, either. They are small, fixed workflows with a few preset options. The “Rename Mobile Devices” routine only allows you to pick from the serial number or assigned username with optional prefix and/or suffix. You can’t specify a different value from the device record or a different source. You also can’t filter the devices based on conditions like the prestage they were enrolled with.

Hand in hand with the above, you can’t do anything custom at all. The building blocks are there, but there’s no interface to build your own routines, or any API access to do so on the backend. This limits your use of Jamf Routines to the available templates, providing feedback to Jamf about the routines you want to have access to, and waiting for Jamf to provide.

No-Code vs Low-Code

In the first paragraph I emphasized the term no-code. Jamf Routines is a true no-code solution that offers the utmost most ease of use. If we step down a level to low-code we give up some user-friendliness in exchange for more control and customization.

All solutions are trade-offs. One core idea with Jamf Routines, that you aren’t writing and responsible for running code to make this happen, still translates to other options even if you end up writing some JSON or YAML.

Let’s explore two different workflow automation services that you can achieve this with.

AWS Step Functions

AWS Step Functions allows you to define a state machine as a document or in a visual editor and have the AWS service handle all of the processing for you. It’s an offering that straddles the line of low-code and no-code (but can also be full of code if you get really deep – which we won’t be doing here).

For this post I’ve gone ahead and created a GitHub repository containing CloudFormation templates that will setup and configure all the resources you need to get started with using Step Functions for low-code Jamf automations, and adapted several of the current Jamf Routines as examples.

Step Functions gives you a lot of flexibility in how you want to work. In the GitHub repo all of the files are in YAML format. While not code, it is still kind of code. The difference here is that the YAML files are documents that describe all the operations and are handed to the AWS services which are doing all the actual heavy lifting.

There is a web interface for creating Step Functions built into the AWS console called Workflow Studio. The screenshot below shows one of the workflows (Redeploy Management Framework) open in the editor. Step Functions are very powerful and have a somewhat overwhelming number of options, but with the example templates you will have references that can help you customize and create your own workflows.

This post will not be a deep dive into Step Functions as a service or authoring Step Functions using Workflow Studio or using the Amazon States Language (ASL), but if there is interest that can be a future post.

Create Base Resources

Before creating any workflows there are some resources that need to be in place.

For the workflows that trigger on webhooks there needs to be an URL that Jamf Pro can send to. This will be fulfilled by two AWS services: API Gateway and EventBridge Event Bus. The API Gateway is our HTTP server accepting webhooks. It will publish those directly to the Event Bus which is where workflows will attach to for matching on events. Event Bus is a messaging service that lets you subscribe many different things to events/messages that match a defined pattern.

The states in our workflows that call the Jamf Pro APIs will need to use an API Connection. This is an EventBridge resource that manages authentication to third-party APIs. It handles the creation of access tokens we need to authenticate.

Create a Jamf Pro API Client

You must first create an API client to obtain the client ID and secret values required for the API connection resource. Full instructions are available at the Jamf Learning Hub: API Roles and Clients.

First create a role by navigating to Settings > System > API roles and clients (you will default to viewing the API Roles tab) and clicking + New. Give the role a name and select all the privileges required for the workflows you will deploy. Click Save.

Refer to the table below if you are deploying one of the examples. The Jamf Developer Portal also has pages that list the required API privileges for the Classic API and the Pro API if you are writing your own custom workflows.

Once you have created the role, create the client by navigating to the API Clients tab of the API roles and clients page and clicking + New. Give the client a name, select the role you just created, and click the Enable API Client button. Click Save.

There will be a Generate client secret button on the page for your new client (this button will only be active if the client is not disabled). Click it, and click Create secret on the pop-up. Copy both the client ID and client secret values.

You will not be able to retrieve this secret again. You will have to rotate it, generating a new one, which will invalidate the current.

Deploy the CloudFormation Stack

Download the base_resources.yaml file. Log into your AWS account and go to the CloudFormation console.

Select Create stack > With new resources (standard). Choose Upload a template file and browse for the base_resource.yaml file. Give a meaningful name to the stack, and fill in the URL of your Jamf Pro server (without a trailing /), and the client ID and secret you copied from the previous step.

Click Next, click Next again, and then check all the boxes under “Transforms might require access capabilities” before clicking Submit. CloudFormation will now create the stack and all the resources.

Once the stack reached the state CREATE_COMPLETE you can navigate to the Outputs tab where the values needed for launching the example workflow stacks and creating webhooks will be found.

Create the Workflows

The steps for creating the stacks for the provided example workflows are nearly identical to the steps for creating the base resources. The key difference is that you will need to fill in different parameters. Each template requires the Jamf Pro URL as well as the ARN (Amazon Resource Name) of the API connection. Workflows that trigger on webhooks will need the name of the event bus. You can copy all those values from the Outputs tab of the base resources stack you created!

The workflows that use webhooks will not run until you’ve created the corresponding webhook in Jamf Pro!

Create Jamf Pro Webhooks (As Needed)

You will now need to create the webhooks in Jamf Pro required for the workflows you have created.

Create a webhook in Jamf Pro by navigating to Settings > Global > Webhooks and clicking + New. Git the webhook a name. For Webhook URL copy the value of WebhooksApiUrl from the outputs of the base resources CloudFormation stack.

Set the Authentication Type to Header Authentication. In the text field you need to paste in the following JSON replacing <VALUE> with the value of WebhooksApiKey from the outputs of the base resources CloudFormation stack.

{  
    "x-api-key": "<VALUE>"
}

Under Content Type ensure JSON is selected and select the appropriate webhook event. For smart group events you have the option of sending only membership changes for a specific group instead of all.

Now, Experiment!

Once you have created the base resources and one or two of the example workflow stacks you now have everything you need to start playing around with customizing or writing your own! Go to the Step Functions console, select one of the workflows, and then click Edit*. You will be taken into Workflow Studio and can now explore the state machine and modify it as you see fit.

If you make a mistake here don’t worry about it! You can delete the CloudFormation stack and create a new one right away. You can even create as many of them as you want (be careful that you don’t do so in a way where you’re triggering multiple workflows off live events – create a test environment!).

If you want to save your changes click Actions and under Export definition… click As YAML file to download it. This will save as an ASL file that you can load into Workflow Studio or use in a CloudFormation template of your own.

There are two types of triggers for the three examples workflows: EventBridge Rules, and EventBridge Schedules. The rules are attached to the event bus created in the base resources stack that the API Gateway is sending webhooks to. Schedules are as they sound, and template for restarting mobile devices uses a cron job syntax for input.

Here is the rule for the redeploy management framework workflow.

Events:  
  Group:  
    Type: EventBridgeRule  
    Properties:  
      EventBusName: !Ref WebhooksEventBusName  
      Pattern:  
        source:  
          - Webhooks API  
        detail-type:  
          - Jamf Pro Webhook  
        detail:  
          event:
            jssid:  
              - !Ref ComputerGroupId  
          webhook:  
            webhookEvent:  
              - SmartGroupComputerMembershipChange

This is attached to the Step Function resource. The important part is the Pattern* that defines which JSON keys need to be matched on, and then arrays of values to match against.

This pattern is matching source and detail-type against fixed values that the API Gateway sets. The detail will contain the raw webhook body matching the webhookEvent to the exact value of SmartGroupComputerMembershipChange and jssid against the group ID value entered in the stack’s input parameter.

The schedule for the restart mobile devices workflow is even simpler.

Events:  
  Scheduled:  
    Type: ScheduleV2  
    Properties:  
      ScheduleExpression: !Sub "cron(${Schedule})"

This is string substitution syntax in CloudFormation. The value for the cron string from the stack’s input parameter is rendered inside cron() which the schedule takes (tip: there’s also a rate() expression that lets you do schedules like rate(5 minutes) or rate(1 hour) if you don’t need to control specifics).

Explore the templates, deploy them in your AWS account, and explore them some more in the console. Experiment and try to create an entirely new workflow or a modified version of an example.

About AWS Costs: All of the resources in this repository are serverless and are charged based on usage. If you don’t use them you won’t incur costs. A rough estimate I have created using the AWS calculator puts the cost close to ~$4.00 per 1,000,000 workflows executions.

Need help?

If you have questions, or encounter an issue with the example templates I have provided in the repository, please reach out to me and I’ll do my best to answer or fix the problem.

Flexli Workflows

There are a lot of automation tools out there, and a wide selection of no-code/low-code workflow services. Over the past year I’ve been building one of my own, and it’s available on GitHub at github.com/brysontyrrell/flexli-engine. It is designed as a fully managed service so the users are not expected to write any code, but instead write JSON documents that describe the APIs and workflows Flexli will work with.

We already covered Step Functions which are incredibly powerful and if you’re an AWS customer you can get started with them now.

Flexli Workflows is currently in a private preview until June 30th, 2024. Read more about it here.

Flexli’s API exposes two main resources: connectors and workflows.

Connectors define the third-party APIs you want to use in your workflows, with configuration details like credentials and default headers, as well as the events and actions available. You can take the same type of connector, like one for Jamf Pro, and create as many instances of it as you want so you can have workflows that work across many different instances.

Workflows use the events and actions from one or more connectors. You can define a source, which is the trigger, and then the sequence of actions that run after. A workflow can only have one source, but you can use any number of actions across any of your connected APIs.

An example connector and three example workflows based upon the published Jamf Routines (same as with Step Functions above) have been posted in the repository. In the JSON snippets anywhere you see {{...}} it’s a placeholder for a value you would fill in.

To dive into connectors and workflows with Flexli you can check out the user guide and the API docs. This post will only cover the basics for the provided examples.

Creating the Jamf Pro Connector

The example Jamf Pro connector only contains the events and actions necessary for the included examples. The configuration requires an API client just like with Step Functions. The service uses this client to obtain and manage access tokens much like the Step Functions connection resource.

{
	"config": {
	    "host": "{{example.jamfcloud.com}}",  
	    "default_headers": {  
	        "Accept": "application/json",  
	        "Content-Type": "application/json"  
	    },  
	    "credentials": {  
	        "type": "OAuth2Client",  
	        "token_url": "https://{{example.jamfcloud.com}}/api/oauth/token",  
	        "client_id": "{{Client ID}}",  
	        "client_secret": "{{Client Secret}}",  
	        "basic_auth": false,  
	        "headers": {  
	            "Content-Type": "application/x-www-form-urlencoded"  
	        },  
	        "body": {  
	            "grant_type": "client_credentials"  
	        }  
	    }  
	}
}

Connectors can be configured for a variety of authentication methods with enough options to (hopefully) cover the many variations of authentication that exist. Values in default_headers will be applied to every action that’s defined automatically.

Events are straightforward. Each event requires a type which can be any name that’s unique within the connector. I have only defined two for the example. There are more advanced options for additional validation, but here I have mapped the Jamf webhook name to the event type.

{
    "events": [
        {"type": "SmartComputerGroupMembershipChange"},
        {"type": "MobileDeviceEnrolled"}
    ]
}

Flexli’s Events API takes in requests at a path that contains the ID of the connector that’s created. It then matches the type of the event against workflows that use it as a source to trigger them.

Actions map to API operations, or abstract API operations by only exposing the inputs needed for a specific task. Here’s the definition for the Pro API operation GET /api/v2/mobile-devices/{id}. The parameters object is a JSON schema that defines the interface for using this action. The parameters are used in the other elements of (like the path below). The workflow author doesn’t need to worry about the rest of the details, only the parameters required.

{
    "actions": [
        {
            "type": "GetMobileDeviceDetailsV2",
            "method": "get",
            "path": "api/v2/mobile-devices/{device_id}",
            "parameters": {
                "type": "object",
                "properties": {
                    "device_id": {
                        "type": "number"
                    }
                }
            }
        }
    ]
}

Creating the connector returns a unique identifier to it that will be used in the workflows.

Create the Workflows

A Flexli workflow is a single JSON document that defines the source and actions performed in response. If the source is an event type the body of the original request is passed in. The rename mobile devices workflow uses the MobileDeviceEnrolled event type.

{
    "source": {  
        "connector_id": "{{Jamf Connector ID}}",  
        "type": "MobileDeviceEnrolled"  
    }
}

The workflow for redeploying the management framework is a bit more complex. It contains a condition that matches the event against a specific group ID and checks that the list of groupAddedDevicesIds isn’t empty. If a source condition check doesn’t pass the workflow doesn’t run.

The first action calling GetMobileDeviceDetailsV2 is conditional. Like in the Step Function example, the device’s management ID may not be present if the version of Jamf Pro is less than 11.4. This action’s condition checks if the managementId property exists, and if not then the action to read the device record runs.

{
    "actions": [
        {  
            "connector_id": "{{Jamf Connector ID}}",  
            "type": "GetMobileDeviceDetailsV2",  
            "description": "Get the management ID if not present in the webhook event.",  
            "order": 1,  
            "condition": {  
                "criteria": {  
                    "attributes": [  
                        {  
                            "type": "Boolean",  
                            "attribute": "::contains(event.managementId)",  
                            "operator": "eq",  
                            "value": false  
                        }  
                    ]  
                }  
            },  
            "parameters": {
                "device_id": "::event.id"  
            },  
            "transform": {  
                "event.managementId": "::general.managementId"  
            }  
        }
    ]
}

The :: syntax you see throughout these examples is a marker for JMESPath expressions. This is a JSON query language similar to how XPath is an XML query language.

JMESPath has functions like contains() which returns true/false if the path is found. Inside the function is a path to event.managementId which is a nreference to the worklfow’s data (which at the start is the source webhook data).

The device_id is the one parameter required for the GetMobileDeviceDetailsV2 action (see above!) and is a path to event.id.

The transform at the end writes values back into the workflow data from the API response. This transform is writing the management ID back into the event where it would be expected in an 11.4+ webhook.

The second and last action calls RenameDeviceCommand and passes in the serial number of the device and the management ID (whether it was always included or populated by the previous conditional action).

{
    "actions": [
        {  
            "connector_id": "{{Jamf Connector ID}}",  
            "type": "RenameDeviceCommand",  
            "order": 2,  
            "parameters": {  
                "device_name": "::event.serialNumber",  
                "management_id": "::event.managementId"  
            }
        }
    ]
}

Explore the Examples

I recreated the same three workflows for Flexli as I did with Step Functions. Check out the redeploy management framework and restart mobile devices workflows for some of the other features of the workflow schema. Read the user guide and check out the API docs (linked at the start of the section) to learn more about Flexli’s workflow capabilities.

If you’re using an editor like Visual Studio Code you can reference the JSON schema files for connectors and workflows to enable auto-completion while experimenting.

If you want to learn more about Flexli Engine and its development please reach out to me.

Appendix

Jamf Routines API Role Permissions

  • Create Mobile Devices
  • Read Mobile Devices
  • Update Mobile Devices
  • Read Smart Mobile Device Groups
  • Read Static Mobile Device Groups
  • Send Mobile Device Restart Device Command
  • Send Mobile Device Set Device Name Command
  • Read Computers
  • Read Smart Computer Groups
  • Read Static Computer Groups
  • Send Computer Remote Command to Install Package
  • Read Computer Check-In
  • Read User
  • Read Smart User Groups
  • Read Static User Groups
  • Create API Roles
  • Read API Roles
  • Update API Roles
  • Delete API Roles
  • Create API Integrations
  • Read API Integrations
  • Update API Integrations
  • Delete API Integrations
  • Create Webhooks
  • Read Webhooks
  • Update Webhooks
  • Delete Webhooks
Design a site like this with WordPress.com
Get started