Skip to content

Reference

The Swift SDK

Read CMS Orbit content in iOS, macOS and server-side Swift apps: install, set up, every method, your own types, SwiftUI, preview and errors.

Reference10 min read

The Swift SDK, CMSOrbit, reads published content from the delivery API in iOS, iPadOS, macOS, tvOS and watchOS apps, and in server-side Swift. It does what the client in the JavaScript SDK does: pages, redirects, menus, site-wide content, lists, banners, popups, products, sitemaps and the schema, with the same retries and errors. It is open source under the MIT licence.

  • Built on async/await and URLSession, with no other dependencies.
  • Safe under Swift 6 strict concurrency: the client and every model are Sendable.
  • Every answer has a Codable model. Fields you have not typed yet arrive as a JSONValue, and you can decode them into your own types at any time.

Install

It needs Swift 5.9 or later, and iOS 15, macOS 12, tvOS 15 or watchOS 8 or later.

In Xcode, choose File › Add Package Dependencies… and enter https://github.com/bifrost-orbit/cms-sdk-swift. In a Package.swift:

Swift
dependencies: [    .package(url: "https://github.com/bifrost-orbit/cms-sdk-swift", from: "0.1.0"),],targets: [    .target(name: "App", dependencies: [.product(name: "CMSOrbit", package: "cms-sdk-swift")]),]

Set up

Create a public key in Configuration › API keys. It starts with cms_pk_ and reads published content only, so it may ship in an app.

Swift
import CMSOrbitlet cms = try CMSOrbitClient(    apiKey: "cms_pk_...",    region: "germany",    locale: "de-DE")let page = try await cms.page("/products/hiking-boots")
apiKey
What it does
A public key, cms_pk_…. Required. A secret cms_sk_… key is refused, because anything inside an app can be read by whoever installs it.
environment
What it does
.production, the default, at https://api.bifrostorbit.com/cms; .staging at https://api-stage.bifrostorbit.com/cms; or .custom(url). Use a key from the same environment.
baseURL
What it does
Overrides the environment's address.
region
What it does
Used when a call names no region, such as germany or de.
locale
What it does
Used when a call names no language, such as de-DE. Without it, the region's default language answers.
previewKey
What it does
A secret key for drafts, for server-side Swift only. Refused on iOS, tvOS, watchOS and visionOS. See Preview.
timeout
What it does
Seconds per attempt. 5 by default.
retries
What it does
Extra attempts after a connection failure or a server error. 2 by default.
session
What it does
Your own URLSession, for a custom cache or for tests.

Create one client and share it across the app.

Methods

Most calls take CMSRequestOptions: region, locale, strict to refuse every fallback, includeReferences to replace Reference fields' ids with the entries, and preview. Anything left out uses the client's defaults.

resolve(path, referrer:)
Reads
/resolve
Gives
.page, .redirect, .gone or .notFound
page(path), findPage(path)
Reads
/content
Gives
A CMSPage. findPage gives nil when nothing is there.
content(MyPage.self, path:)
Reads
/content
Gives
The page decoded into your own type
navigation(key), findNavigation(key)
Reads
/navigation
Gives
A menu
global(key), findGlobal(key)
Reads
/global
Gives
A site-wide entry, such as the footer
list(options), list(options, fields: MyFields.self)
Reads
/list
Gives
Entries of one content type
regions(), locales(region:)
Reads
/regions
Gives
The regions and languages the key may read
taxonomy(key, locale:)
Reads
/taxonomies/{key}
Gives
A taxonomy's terms
banners(.path(path)), banners(.everyPage)
Reads
/banners
Gives
Banners live now
popups(path:)
Reads
/popups
Gives
Popups live now
products(page:perPage:filter:)
Reads
/products
Gives
Shop products with live prices
sitemapIndex(), sitemap(region:locale:page:), robots()
Reads
/sitemap, /robots
Gives
Search engine files
schema()
Reads
/schema
Gives
Content types and components
graphql(query, variables:, as:)
Reads
/graphql
Gives
Your own type. See GraphQL.
verifyPreview(token:)
Reads
/preview
Gives
Which entry a preview link is for
get(MyType.self, path:query:)
Reads
Any endpoint
Gives
Your own type, for endpoints the SDK does not cover yet

Call resolve before showing an address, so redirects and removed pages behave correctly:

Swift
switch try await cms.resolve("/offers/winter") {case .page(let page): show(page)case .redirect(let redirect): open(redirect.to)case .gone, .notFound: showNotFound()}

page.meta compares what answered with what was asked for. When page.meta.fallbackApplied is true, another language answered, so the app can say "This page is not available in English yet." See Regions and languages.

To read another region or language than the client's defaults, or to refuse every fallback:

Swift
let page = try await cms.page(    "/products/hiking-boots",    options: CMSRequestOptions(region: "ch", locale: "fr-CH", strict: true))

Menus and site-wide content are read by their key:

Swift
let header = try await cms.navigation("header")for item in header.items {    print(item.label, item.href ?? "", item.children.count)}let footer = try await cms.global("footer")let copyright = footer.fields["copyright"]?.stringValue

Lists take the same filters as the API:

Swift
let products = try await cms.list(CMSListOptions(    type: "product",    include: [.fields],    filter: ["activity": "hiking"],    sort: .publishedAtDescending))

A list has items, total, page and hasMore. perPage is 20 by default and at most 100, so ask for the next page while hasMore is true.

Your own types

Fields arrive as JSONValue, which you can read straight away with page.fields["title"]?.stringValue. For type safety, write Decodable structs. The SDK's models, such as CMSImage, CMSLink, CMSComponent and CMSReference, decode inside them.

Swift
struct PageFields: Decodable, Sendable {    var title: String    var heroImage: CMSImage?}let fields = try page.fields(as: PageFields.self)

Each component type gets a struct conforming to CMSBlockFields, which names the component's key:

Swift
struct HeroBlock: CMSBlockFields {    static let blockType = "hero"    var heading: String    var image: CMSImage?    var link: CMSLink?}

block.decode(HeroBlock.self) gives the fields when the block is a hero, and nil for any other type. Reference fields hold entry ids; read with includeReferences: true and type them as CMSReference<FaqEntry>, which is either .id or .entry. See Field types for what each field type arrives as.

Generate types

Instead of writing the structs yourself, let the type generator in the JavaScript package write them from your space's schema: one Codable struct per component and content type. Run it with Node 20 or later whenever the schema changes:

Shell
npx --package @bifrost-orbit-cms/sdk cms-orbit generate --lang swift --out Sources/App/CMSTypes.swift

It reads CMS_API_URL and CMS_PUBLIC_KEY, or --url and --key. Add --include references if you load references with include.

Components in SwiftUI

A page's body is page.components: each block has an id, type, fields, children (the blocks in each slot) and variant. Switch on the type, and draw nothing for types the app does not know yet, so a new component in the CMS never breaks the app:

Swift
struct BlockView: View {    let block: CMSComponent    var body: some View {        if let hero = try? block.decode(HeroBlock.self) {            HeroView(hero: hero)        } else if let grid = try? block.decode(CardGridBlock.self) {            CardGridView(grid: grid, cards: block.children["cards"] ?? [])        } else {            EmptyView()        }    }}struct PageView: View {    let page: CMSPage    var body: some View {        ScrollView {            LazyVStack(spacing: 24) {                ForEach(page.components) { BlockView(block: $0) }            }        }        .navigationTitle(page.name)    }}
  • image.url(width: 1280) asks the CMS for a resized copy of an uploaded image, so the app never downloads the original.
  • Rich text arrives as HTML. Show it with AttributedString or a web view.
  • Blocks in a slot arrive both in block.children[slot] and on the slot's field.

See Render components for how pages are built from components.

Insights Orbit

When the space is connected to Insights Orbit under Settings, each page carries id, the entry's id, and insights, a CMSInsightsSite with the site key, or nil. Both are nil from an older CMS Orbit.

In server-side Swift that renders HTML, CMSInsights.tags writes the Insights Orbit script with the site key and <meta name="insights-content"> with the entry's id, so the page's numbers follow it when its address changes. Without a site key it answers with an empty string:

Swift
let head = CMSInsights.tags(page: page)// or with your own key and a first-party address:let head = CMSInsights.tags(page: page, site: "SITE_KEY", src: "/stats/script.js", api: "/stats/collect")

contentId tags page views with another entry, disable switches off automatic events, such as ["scroll"], and campaigns: false stops counting elements marked with data-banner or data-popup. Insights Orbit counts websites, so an app has no use for the script.

Preview

The CMS editor opens your website with ?preview= and a token for one page's draft. If a preview link opens your app, for example as a universal link, read the token and ask which entry it is for. This needs no secret key:

Swift
if let token = CMSOrbitClient.previewToken(from: url) {    let session = try await cms.verifyPreview(token: token)    print(session.name, session.url ?? "")}

Tokens last an hour, and an invalid or expired one throws .unauthorized. session.draft is the editor's saved draft as the CMS stores it, with image ids rather than images.

Reading drafts through the delivery API needs a secret key, which must never be inside an app. In server-side Swift on macOS or Linux, give the client a previewKey and pass CMSRequestOptions(preview: true). An app that needs to show drafts should ask your own server, which holds the key. Secret keys exist for staging environments only. See Keys, permissions and sign-in.

Errors

Every call throws CMSOrbitError, or CancellationError when its task is cancelled.

.notFound
When
404: nothing at the address, or no menu, global entry, taxonomy, region or language with that key
.unauthorized
When
401: the key was refused or has expired, or may not read that region or language
.rateLimited(retryAfter:)
When
429: wait that many seconds
.http(status:code:message:)
When
Any other 4xx, such as an unknown sort
.server
When
A server error, after the retries
.unavailable
When
The CMS could not be reached or did not answer in time, after the retries
.decoding
When
The answer did not match the type asked for
.graphQL(code:message:)
When
A GraphQL answer carried errors
.secretKeyNotAllowed, .invalidAPIKey, .previewKeyRequired, .invalidURL
When
The client was set up wrongly, or a call was missing something it needs, such as a sitemap without a region

Every error has status, the HTTP status when there was one, and code, such as not_found or rate_limited.

Connection failures and server errors are retried twice, after 250 and 750 milliseconds, and each attempt times out after 5 seconds. Other errors, including 429, are not retried, so wait retryAfter seconds before asking again:

Swift
do {    show(try await cms.page(path))} catch CMSOrbitError.notFound {    showNotFound()} catch CMSOrbitError.rateLimited(let retryAfter, _) {    try await Task.sleep(nanoseconds: UInt64(retryAfter * 1_000_000_000))    show(try await cms.page(path))} catch CMSOrbitError.unavailable, CMSOrbitError.server {    showOfflineCopy()}

Each key may make 15,000 requests a minute. See Rate limits in the delivery API.

Caching

The client does not keep answers itself. Published answers carry Cache-Control with s-maxage, which is meant for shared caches such as a CDN, so URLSession's own cache should not be relied on to keep them. Keep what the app shows in memory or on disk, show it straight away on the next launch, and read it again when the app returns to the foreground. Drafts are always read fresh. See Caching in the delivery API and Caching and refreshing pages.

Testing

Pass a URLSession whose configuration lists your own URLProtocol in protocolClasses, and answer each request with JSON shaped like the delivery API's answers. The SDK's own tests work this way.

Was this page helpful?

Read next