Reference
The Kotlin SDK
Read CMS Orbit content in Android apps and on JVM servers, from Kotlin or Java: install, set up, every method, your own types, Jetpack Compose, preview and errors.
Reference10 min read
The Kotlin SDK, com.bifrostorbit:cms-sdk, reads published content from the delivery API in Android apps and on JVM servers, such as Ktor or Spring Boot. 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.
- Suspend functions for Kotlin coroutines, and
CompletableFutures for Java. - Built on OkHttp and kotlinx.serialization. Bring your own
OkHttpClientto share its connection pool and interceptors. - Every answer has a
@Serializablemodel. Fields arrive as aJsonObject, and you decode them into your own types when you need them.
Install
It needs Java 11 or later on servers, and Android 5.0 (API 21) or later in apps. The Java futures need Android 7.0 (API 24). Kotlin projects need Kotlin 2.2 or later.
The package is com.bifrostorbit:cms-sdk on Maven Central, where you can see the latest version.
// build.gradle.ktsplugins { kotlin("plugin.serialization") version "2.4.21" // to decode fields into your own types}dependencies { implementation("com.bifrostorbit:cms-sdk:0.1.0")}With Maven:
<dependency> <groupId>com.bifrostorbit</groupId> <artifactId>cms-sdk</artifactId> <version>0.1.0</version></dependency>An Android app also needs <uses-permission android:name="android.permission.INTERNET" /> in its manifest.
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 com.bifrostorbit.cms.CmsOrbitClientval cms = CmsOrbitClient("cms_pk_...") { defaultRegion = "germany"}In Java:
CmsOrbitClient cms = CmsOrbitClient.builder("cms_pk_...") .defaultRegion("germany") .build();apiKeycms_pk_…). Required.baseUrlhttps://api.bifrostorbit.com/cms by default.environment(…)PRODUCTION or STAGING, a shortcut for baseUrl. Staging is https://api-stage.bifrostorbit.com/cms.previewKeycms_sk_…) for drafts. Servers only: refused in Android apps.defaultRegiondefaultLocaletimeoutMillisretriesokHttpClient| Option | What it does |
|---|---|
apiKey | A public key (cms_pk_…). Required. |
baseUrl | The API address. https://api.bifrostorbit.com/cms by default. |
environment(…) | PRODUCTION or STAGING, a shortcut for baseUrl. Staging is https://api-stage.bifrostorbit.com/cms. |
previewKey | A secret key (cms_sk_…) for drafts. Servers only: refused in Android apps. |
defaultRegion | Used when a call names no region. |
defaultLocale | Used when a call names no language. Without it, each region answers in its own default language. |
timeoutMillis | Per attempt. 5 seconds by default. |
retries | Extra attempts after a network error or a server error. 2 by default. |
okHttpClient | Your own OkHttp client, for interceptors, logging, a proxy or certificate pinning. |
Create one client and share it. It is safe to use from many coroutines and threads at once.
Secret keys
A secret key reads drafts, so anyone who has it can read unpublished content. The SDK refuses a cms_sk_ key, as apiKey or previewKey, when it runs on Android, because an app's files can be read by anyone who installs it. This matches the JavaScript SDK, which refuses secret keys in browser code. On a JVM server a secret key is allowed: keep it in an environment variable or a secrets manager, never in source control.
Methods
Every method is a suspend function. Call it from a coroutine, such as viewModelScope.launch on Android or a Ktor route.
val result = cms.resolve("/products/hiking-boots")val page = cms.getPage("/products/hiking-boots", RequestOptions(locale = "de-DE"))val header = cms.getNavigation("header")val footer = cms.getGlobal("footer")val articles = cms.list(ListOptions(type = "article", sort = CmsSort.PUBLISHED_AT_DESC, perPage = 12))resolve(path, options, referrer)CmsResolution: Page, Redirect, Gone or NotFound. Call it before showing a page.getPage(path, options), findPage(…)find… answers null instead of throwing when nothing is published there.list(ListOptions)getGlobal(key, options), findGlobal(…)getNavigation(key, options), findNavigation(…)getBanners(path, loggedIn, options)getBannersForEveryPage(loggedIn, options)paths it targetsgetPopups(path, loggedIn, options)getRegions(), getLocales(region)getSitemapIndex(), getSitemap(region, locale, page), getRobots()getProducts(page, perPage, filter, options), getTaxonomy(key, locale)graphql(query, variables, operationName, preview)getSchema()verifyPreviewToken(token)| Method | Answers with |
|---|---|
resolve(path, options, referrer) | CmsResolution: Page, Redirect, Gone or NotFound. Call it before showing a page. |
getPage(path, options), findPage(…) | One page by address. find… answers null instead of throwing when nothing is published there. |
list(ListOptions) | Entries of one content type, filtered and paged, with their fields when asked |
getGlobal(key, options), findGlobal(…) | Site-wide content such as a footer, by its key |
getNavigation(key, options), findNavigation(…) | A menu, by its key |
getBanners(path, loggedIn, options) | Banners live now for a page |
getBannersForEveryPage(loggedIn, options) | Banners live now on any page, each with the paths it targets |
getPopups(path, loggedIn, options) | Popups live now for a page |
getRegions(), getLocales(region) | The regions and languages the key may read |
getSitemapIndex(), getSitemap(region, locale, page), getRobots() | Sitemaps and robots rules |
getProducts(page, perPage, filter, options), getTaxonomy(key, locale) | Shop products with live prices, and taxonomy terms |
graphql(query, variables, operationName, preview) | Any GraphQL query, as a JsonElement. graphqlAs<T>(…) decodes it. |
getSchema() | Content types and components |
verifyPreviewToken(token) | The entry a preview link is for. See Preview. |
RequestOptions holds region, locale, preview, strict and includeReferences. ListOptions adds type, page, perPage, filter, tag, category, sort, q and includeFields. From Java, build them with new RequestOptions().withRegion("de") and new ListOptions("article").withPerPage(12).
A page has meta, which says which region and language answered and whether a fallback was used, so an app can show a notice such as "This page is not available in English yet."
From Java
cms.getAsync() has the same methods, each answering with a CompletableFuture. Call join() to wait for the answer on a server, or chain thenAccept to stay off the calling thread. Cancelling a future cancels its request.
CmsResolution result = cms.getAsync().resolve("/products/hiking-boots").join();if (result instanceof CmsResolution.Page) { CmsPage page = ((CmsResolution.Page) result).getPage(); System.out.println(page.getName());} else if (result instanceof CmsResolution.Redirect) { response.sendRedirect(((CmsResolution.Redirect) result).getTo());}A failed call completes the future with a CompletionException from join(), whose getCause() is one of the exceptions in Errors.
Your own types
Fields arrive as a JsonObject. Declare a @Serializable class with the fields you use, and decode them with decodeFields. Fields you leave out are ignored, and missing or null values take your defaults.
@Serializabledata class FaqEntry(val question: String, val answer: String = "")val faqs = cms.list(ListOptions(type = "faqEntry", category = "pricing", includeFields = true))val entries = faqs.items.map { it.decodeFields<FaqEntry>() }decodeFields is on pages, blocks, global entries, list items and referenced entries. Images decode as CmsImage and links as CmsLink. CmsJson.decode<T>(element) decodes any other JsonElement. A shape that does not match throws CmsDecodingException.
From Java, pass the JSON to your own library: page.getFields().toString() is the fields as JSON text, ready for Jackson or Gson.
Generate types
Instead of writing the classes yourself, let the type generator in the JavaScript package write them from your space's schema: one @Serializable data class 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 kotlin --out src/main/kotlin/cms/CmsTypes.kt --package com.example.cmsIt reads CMS_API_URL and CMS_PUBLIC_KEY, or --url and --key. Add --include references if you load references with include.
Components in Jetpack Compose
A page's components is a list of CmsComponent, each with id, type, fields, children and variant. Decode each into your own class and draw it:
@Serializabledata class Hero(val heading: String, val image: CmsImage? = null, val description: String = "")@Composablefun CmsBlocks(blocks: List<CmsComponent>) { Column { blocks.forEach { block -> when (block.type) { "hero" -> HeroView(block.decodeFields<Hero>()) "card-grid" -> CardGridView(block) else -> Unit } } }}@Composablefun HeroView(hero: Hero) { Column { hero.image?.let { AsyncImage(model = CmsImages.url(it.url, width = 1080), contentDescription = it.alt) } Text(hero.heading, style = MaterialTheme.typography.headlineLarge) Text(AnnotatedString.fromHtml(hero.description)) }}Load the page in a view model, so it survives screen rotation:
class PageViewModel(private val cms: CmsOrbitClient) : ViewModel() { var result by mutableStateOf<CmsResolution?>(null) private set fun load(path: String) = viewModelScope.launch { result = try { cms.resolve(path) } catch (error: CmsOrbitException) { null } }}AsyncImageis from Coil.CmsImages.urlasks the CMS for an image resized to the width you show it at, andCmsImages.widthslists the sizes on offer.- Rich text fields are HTML.
AnnotatedString.fromHtmlin Compose 1.7 shows headings, bold, italics and links; for tables and embeds, use aWebView. - Blocks in a slot are on the slot's field and in
children, as in Render components. Decode a Components field asList<CmsComponent>and pass it toCmsBlocksagain. - An unknown block type draws nothing, so an app that is not yet updated keeps working when editors add a component.
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 null. Both are null from an older CMS Orbit.
On a server 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:
val head = CmsInsights.tags(page)// or with your own key and a first-party address:val head = CmsInsights.tags(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 listOf("scroll"), and campaigns = false stops counting elements marked with data-banner or data-popup. From Java, call CmsInsights.tags(page) or CmsInsights.tags(page, "SITE_KEY"). Insights Orbit counts websites, so an Android app has no use for the script.
Preview
Drafts need a secret key, which an Android app may not hold. Preview drafts on a JVM server instead, such as the server that renders your website.
- Give the client a
previewKeywith the secret key. - When the editor opens your website with
?preview=…, check the token withverifyPreviewToken. It answers with the entry, including itsurl, or throwsCmsUnauthorizedExceptionwhen the token is invalid or has expired. - Remember that the visitor is previewing, such as in a session cookie, and read pages with
RequestOptions(preview = true).
get("/api/preview") { val token = call.parameters["preview"] ?: return@get call.respond(HttpStatusCode.BadRequest) val entry = try { cms.verifyPreviewToken(token) } catch (error: CmsUnauthorizedException) { return@get call.respond(HttpStatusCode.Unauthorized) } call.sessions.set(PreviewSession(enabled = true)) call.respondRedirect(entry.url ?: "/")}Only redirect to addresses on your own website. Asking for a preview without a previewKey throws an IllegalStateException before any request is sent. See Set up the live preview.
Errors
Failed calls throw a CmsOrbitException, or one of its kinds. Each has the HTTP status and the API's code.
CmsNotFoundExceptionCmsUnauthorizedExceptionCmsRateLimitedExceptionretryAfter is the number of seconds to wait.CmsServerExceptionCmsDecodingExceptionCmsOrbitException400 with the code bad_request| Exception | When |
|---|---|
CmsNotFoundException | Nothing published at the address, or no menu, global entry, taxonomy, region or language with that key |
CmsUnauthorizedException | A missing, unknown, expired or refused key, a public key asking for drafts, or a region or language the key may not read |
CmsRateLimitedException | Too many requests. retryAfter is the number of seconds to wait. |
CmsServerException | The CMS failed, could not be reached or did not answer in time, after every retry |
CmsDecodingException | The answer, or your own type, could not be decoded |
CmsOrbitException | Anything else, such as 400 with the code bad_request |
Network errors and server errors are retried twice, after 250 and 750 milliseconds, and each attempt times out after 5 seconds. Client errors, including 429, are never retried. Cancelling the coroutine cancels the request.
val page = try { cms.getPage(path)} catch (error: CmsNotFoundException) { null} catch (error: CmsRateLimitedException) { delay(error.retryAfter * 1_000L) cms.getPage(path)} catch (error: CmsServerException) { cachedPage}Each key may make 15,000 requests a minute. See Rate limits in the delivery API.
Caching
The client does not cache answers. The API's Cache-Control is meant for shared caches such as a CDN (s-maxage), so OkHttp's own cache does not keep them; see Caching in the delivery API. In an app, keep what you show in memory or in a local database, and read it again when the app returns to the foreground. On a server, keep answers for about a minute, or refresh them when publish webhooks arrive.
Was this page helpful?