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: 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.
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. Fo to 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:
Plugin:
This package contains functionality used by the generated client code.
Transport: 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 .
Press ⌘ + N and create a new Swift file in your project called JamfAPIClient.swift.
Add these imports:
CODE// 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
Clientdirectly.
CODEstruct 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
Clientis being instantiated aURLconcatenated together from the passed hostname. TheURLSessionTransportis the one installed withswift-openapi-urlsessionpackage.
The
Configurationbeing 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.
CODEConfiguration(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:
CODE// Example use
let client = JamfProAPIClient(
hostname: "dummy.jamfcloud.com",
clientID: "43fd12fc...",
clientSecret: "Fn96LFQP..."
)
CODEprint(client.clientId) // Inspect and identify clients
CODElet 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
ClientMiddlewareprotocol 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:
CODEstruct 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
interceptfor 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/tokenthere 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:
CODEstruct 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))
}
}
isExpiredis a computed property that will returntrueif 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
AccessTokenManageras anactorwill 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.
CODEactor 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
AccessTokenManagerwill 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 aTask. 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:
CODEenum JamfProAPIClientError: Error {
case AuthError(String)
}
The method to request access tokens will look similar to many other examples of
URLSessionyou 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 (theAccessTokenstruct above), and HTTP request code.
This code follows
Here is the complete code:
CODEimport 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-inventoryAPI.Componentscontains generated types from the OpenAPI doc. It follows the same structure and names as thecomponentsobject in the doc.
CODE@State private var computerSearchResults: Components.Schemas.ComputerInventorySearchResults?
The view will automatically load data into
computerSearchResultsat launch. Thetaskmodifier contains the client call toComputersInventoryGetV1.
CODElet 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
This is a very elegant interface to what is a fairly complex API.
GET /v1/computers-inventoryuses query string parameters to control and filter the returned computers. Thesectionsare parts of the computer object to include. In code it takes an arrayComputerSectionenums 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.jsonreturns theComputerInventorySearchResultstype. Once this happens the SwiftUI code will automatically render the list.
CODEif 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
resultsproperty is an array ofComputerInventorytypes. If the results have been loaded, theForEachloop 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
computerResultsdoes not conform toIdentifiable. 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
filterof theopenapi-generator-config.yaml.
CODEgenerate:
- 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:
CODElet 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:
CODElet 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
ComputersInventoryGetV1with 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/tokenendpoint used by theAccessTokenManageris 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:
CODE{
"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
pathsobject in the OpenAPI doc. Once added, trigger a new build and the API method will be available. Scroll back to theAccessTokenManagerto remember the code required for that singleURLSessionrequest.
Now compare to the new
AccessTokenRequestmethod:
CODElet 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
ComputersInventoryGetV1set 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
JamfProAPIClientthat wrapsComputersInventoryGetV1, 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.
CODEfunc 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
}
The task code that automatically loads the list of computers can now call this and be guaranteed to fetch the entire inventory for display.
CODEcomputerSearchResults = 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.Queryso Xcode would provide the same autocompletion and help text as the lower-level non-paginated call.
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. I do plan on having all of this available as an example project on GitHub later so all of the files as described are laid out and you can build yourself.
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.../historyAPIs where Jamf generated an invalid content-type label for the200responses. Instead of documenting two types of responses they were concatenated together astext/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. ThePATCH /v2/account-preferencesAPI hasJSESSIONIDas 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)/providerNameshows the schema at issue isCloudLdapServerUpdateand the property that's required but does not exist isproviderName.Invalid discriminator.mapping value... must be an internal JSON reference.
In theMdmCommandRequestthe discriminator mapping still includes external file references. Those schemas all exist within the OpenAPI document. Remove all of the*.yamlprefixes.
Date Decoding Errors
Between two different Jamf Pro instances while testing I have encountered this issue in my console logs when returning device data:
CODEClient 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:
CODEconfiguration: Configuration(dateTranscoder: .iso8601WithFractionalSeconds)
That sets up an
ISO8601DateFormatterwith the following options:
CODEISO8601DateTranscoder(options: [.withInternetDateTime, .withFractionalSeconds])
When
.withFractionalSecondsis 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.
CODEstruct 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:
CODEconfiguration: Configuration(dateTranscoder: CustomDateTranscoder())
This custom date transcoder is also Swift 6 compliant. In Xcode 16 if you try to encode/decode using
ISO8601DateFormatter(as theISO8601DateTranscoderdoes) there will be a warning that it does not conform toSendable.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR