# 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.

Source: https://docs.bifrostorbit.com/cms/developers/sdk-swift/

The Swift SDK, `CMSOrbit`, reads published content from the [delivery API](https://docs.bifrostorbit.com/cms/developers/delivery-api/) in iOS, iPadOS, macOS, tvOS and watchOS apps, and in server-side Swift. It does what the client in the [JavaScript SDK](https://docs.bifrostorbit.com/cms/developers/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 CMSOrbit

let cms = try CMSOrbitClient(
    apiKey: "cms_pk_...",
    region: "germany",
    locale: "de-DE"
)

let page = try await cms.page("/products/hiking-boots")
```

| 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](#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.

| 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](https://docs.bifrostorbit.com/cms/developers/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:

```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](https://docs.bifrostorbit.com/cms/developers/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](https://docs.bifrostorbit.com/cms/developers/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:

```bash
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](https://docs.bifrostorbit.com/cms/developers/render-blocks/) 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](https://docs.bifrostorbit.com/cms/developers/security-and-access/).

## Errors

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

| 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:

```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](https://docs.bifrostorbit.com/cms/developers/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](https://docs.bifrostorbit.com/cms/developers/delivery-api/) and [Caching and refreshing pages](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/).

## Testing

Pass a `URLSession` whose configuration lists your own `URLProtocol` in `protocolClasses`, and answer each request with JSON shaped like the [delivery API](https://docs.bifrostorbit.com/cms/developers/delivery-api/)'s answers. The SDK's own tests work this way.
