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:
- Plugin: https://github.com/apple/swift-openapi-generator This package plugin is what will generate all of the client and type code from the OpenAPI document each time you build your project.
- Runtime: https://github.com/apple/swift-openapi-runtime This package contains functionality used by the generated client code.
-
Transport: https://github.com/apple/swift-openapi-urlsession
Transports are what performs the HTTP operations for your client. This transport uses
URLSession
. There is another available for theAsyncHTTPClient
package.
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
isserver
. 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:
- 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.
- Check if there is a current token and that it is not expired. If the token exists and is valid it will be returned.
- 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.
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!)")
.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 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 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 init
s 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 thetotalCount
andresults
of the inventory response. This is intentional: those values are guaranteed to exist. Neither of these can actually ever benil/null
. The API will return a0
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 theComputerInventory
schema you'll find that theid
, 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]
)
)
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 the200
responses. 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-preferences
API hasJSESSIONID
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 isCloudLdapServerUpdate
and the property that's required but does not exist isproviderName
.Invalid discriminator.mapping value... must be an internal JSON reference.
In theMdmCommandRequest
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: .init(dateTranscoder: CustomDateTranscoder())
This custom date transcoder is also Swift 6 compliant. In Xcode 16 if you try to encode/decode using
ISO8601DateFormatter
(as theISO8601DateTranscoder
does) there will be a warning that it does not conform toSendable
.
Top comments (0)