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/awaitandURLSession, with no other dependencies. - Safe under Swift 6 strict concurrency: the client and every model are
Sendable. - Every answer has a
Codablemodel. Fields you have not typed yet arrive as aJSONValue, 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:
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.
import CMSOrbitlet cms = try CMSOrbitClient( apiKey: "cms_pk_...", region: "germany", locale: "de-DE")let page = try await cms.page("/products/hiking-boots")apiKeycms_pk_…. Required. A secret cms_sk_… key is refused, because anything inside an app can be read by whoever installs it.environment.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.baseURLregiongermany or de.localede-DE. Without it, the region's default language answers.previewKeytimeoutretriessessionURLSession, for a custom cache or for tests.| Option | What it does |
|---|---|
apiKey | 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 | .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 | Overrides the environment's address. |
region | Used when a call names no region, such as germany or de. |
locale | Used when a call names no language, such as de-DE. Without it, the region's default language answers. |
previewKey | A secret key for drafts, for server-side Swift only. Refused on iOS, tvOS, watchOS and visionOS. See Preview. |
timeout | Seconds per attempt. 5 by default. |
retries | Extra attempts after a connection failure or a server error. 2 by default. |
session | 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:)/resolve.page, .redirect, .gone or .notFoundpage(path), findPage(path)/contentCMSPage. findPage gives nil when nothing is there.content(MyPage.self, path:)/contentnavigation(key), findNavigation(key)/navigationglobal(key), findGlobal(key)/globallist(options), list(options, fields: MyFields.self)/listregions(), locales(region:)/regionstaxonomy(key, locale:)/taxonomies/{key}banners(.path(path)), banners(.everyPage)/bannerspopups(path:)/popupsproducts(page:perPage:filter:)/productssitemapIndex(), sitemap(region:locale:page:), robots()/sitemap, /robotsschema()/schemaverifyPreview(token:)/previewget(MyType.self, path:query:)| Method | Reads | Gives |
|---|---|---|
resolve(path, referrer:) | /resolve | .page, .redirect, .gone or .notFound |
page(path), findPage(path) | /content | A CMSPage. findPage gives nil when nothing is there. |
content(MyPage.self, path:) | /content | The page decoded into your own type |
navigation(key), findNavigation(key) | /navigation | A menu |
global(key), findGlobal(key) | /global | A site-wide entry, such as the footer |
list(options), list(options, fields: MyFields.self) | /list | Entries of one content type |
regions(), locales(region:) | /regions | The regions and languages the key may read |
taxonomy(key, locale:) | /taxonomies/{key} | A taxonomy's terms |
banners(.path(path)), banners(.everyPage) | /banners | Banners live now |
popups(path:) | /popups | Popups live now |
products(page:perPage:filter:) | /products | Shop products with live prices |
sitemapIndex(), sitemap(region:locale:page:), robots() | /sitemap, /robots | Search engine files |
schema() | /schema | Content types and components |
graphql(query, variables:, as:) | /graphql | Your own type. See GraphQL. |
verifyPreview(token:) | /preview | Which entry a preview link is for |
get(MyType.self, path:query:) | Any endpoint | Your own type, for endpoints the SDK does not cover yet |
Call resolve before showing an address, so redirects and removed pages behave correctly:
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:
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:
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"]?.stringValueLists take the same filters as the API:
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.
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:
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:
npx --package @bifrost-orbit-cms/sdk cms-orbit generate --lang swift --out Sources/App/CMSTypes.swiftIt 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:
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
AttributedStringor 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:
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:
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.
.notFound404: nothing at the address, or no menu, global entry, taxonomy, region or language with that key.unauthorized401: the key was refused or has expired, or may not read that region or language.rateLimited(retryAfter:)429: wait that many seconds.http(status:code:message:)4xx, such as an unknown sort.server.unavailable.decoding.graphQL(code:message:).secretKeyNotAllowed, .invalidAPIKey, .previewKeyRequired, .invalidURL| Error | When |
|---|---|
.notFound | 404: nothing at the address, or no menu, global entry, taxonomy, region or language with that key |
.unauthorized | 401: the key was refused or has expired, or may not read that region or language |
.rateLimited(retryAfter:) | 429: wait that many seconds |
.http(status:code:message:) | Any other 4xx, such as an unknown sort |
.server | A server error, after the retries |
.unavailable | The CMS could not be reached or did not answer in time, after the retries |
.decoding | The answer did not match the type asked for |
.graphQL(code:message:) | A GraphQL answer carried errors |
.secretKeyNotAllowed, .invalidAPIKey, .previewKeyRequired, .invalidURL | 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:
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?