From 4c07578098c2f8ceb79868339d0fe830ab9b1988 Mon Sep 17 00:00:00 2001 From: Yusuf Akinleye Date: Mon, 3 Aug 2026 16:13:26 +0100 Subject: [PATCH 1/2] docs: rewrite README for third-party developers --- CONTRIBUTING.md | 47 +++++++ README.md | 367 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 331 insertions(+), 83 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..80b03ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing + +Everything under `sdk/` is generated. Do not edit it by hand — the next +regeneration will overwrite your changes. Fix the OpenAPI spec instead, then +regenerate. + +## Regenerating the SDK + +The client is produced by [Kiota](https://learn.microsoft.com/openapi/kiota/) +1.34.1 from the upstream Rixl OpenAPI spec. With the `kiota` CLI on your PATH: + +```bash +./gen.sh +``` + +That runs: + +```bash +kiota generate \ + -l java \ + -c RixlClient \ + -n com.rixl.sdk \ + -d https://raw.githubusercontent.com/rixlhq/openapi/refs/heads/main/openapi.yaml \ + -o "./sdk" \ + --clean-output \ + --exclude-backward-compatible +``` + +`--clean-output` wipes `sdk/` first, so regeneration is the only way changes to +the spec reach this repo. Commit the result on its own, without mixing in +hand-written changes. + +## Building + +```bash +mvn -q compile +``` + +The build compiles `sdk/` directly (`sourceDirectory` in `pom.xml`) and targets +Java 17. + +## Releasing + +Releases are cut by release-please from conventional commits. The version lives +in `pom.xml` and `.release-please-manifest.json`, and both are updated by the +release PR — do not bump them by hand. When the version changes, update the +install snippets in `README.md` to match. diff --git a/README.md b/README.md index 45bde6c..1340976 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,356 @@ -# rixl-java +# Rixl Java SDK -Java client for the [RIXL](https://rixl.com) API. +The official Java client for the [Rixl](https://rixl.com) API. -Published to Maven Central. Requires Java 17+. +Rixl handles the media side of your product — uploading and delivering images +and videos, organising them into feeds and posts, and reporting on how people +engage with them. It also covers the account layer around that: users and +organisations, sign-in, subscriptions and invoices. This SDK gives you all of it +from Java, as a fluent request builder per path and a class for every request +and response body. -## Install +Requires Java 17 or later. -### Maven +## Installation + +Maven: ```xml com.rixl sdk - 1.0.0 + 1.1.0 ``` -### Gradle +Gradle: ```gradle -implementation("com.rixl:sdk:1.0.0") +implementation("com.rixl:sdk:1.1.0") ``` -## Quick start +That pulls in the Kiota runtime the generated code is built on — the +abstractions, the OkHttp transport and the JSON, form, text and multipart +serializers. + +## Getting started + +You build a client out of two pieces: something that authenticates requests, and +a request adapter that sends them. Then you point the adapter at the API: ```java -import com.rixl.sdk.RixlClient; import com.microsoft.kiota.authentication.ApiKeyAuthenticationProvider; -import com.microsoft.kiota.authentication.KeyLocation; +import com.microsoft.kiota.authentication.ApiKeyLocation; import com.microsoft.kiota.http.OkHttpRequestAdapter; +import com.rixl.sdk.RixlClient; -var auth = new ApiKeyAuthenticationProvider( - "YOUR_RIXL_API_KEY", "X-API-Key", KeyLocation.Header); -var adapter = new OkHttpRequestAdapter(auth); -var client = new RixlClient(adapter); +public class Example { + public static void main(String[] args) { + var auth = new ApiKeyAuthenticationProvider( + System.getenv("RIXL_API_KEY"), "X-API-Key", ApiKeyLocation.HEADER); + + var adapter = new OkHttpRequestAdapter(auth); + adapter.setBaseUrl("https://api.rixl.com"); + + var client = new RixlClient(adapter); -var image = client.images().byImageId("PS5IMKoFLm").get(); -System.out.println(image.getId() + " " + image.getWidth() + "x" + image.getHeight()); + var page = client.media().v1().projects() + .byProject_id(System.getenv("RIXL_PROJECT_ID")) + .images() + .get(); + + page.getImages().forEach(image -> System.out.println(image.getId())); + } +} ``` -Default base URL: `https://api.rixl.com`. Override with `adapter.setBaseUrl(...)`. +The adapter has no base URL of its own, so `setBaseUrl` is not optional — call +it before you make a request, and point it somewhere else when you are testing +against another environment. + +Calls are synchronous: each one returns the parsed response, or throws. ## Authentication -API key: +There are two ways to identify yourself, and they answer different questions. -```java -import com.microsoft.kiota.authentication.ApiKeyAuthenticationProvider; -import com.microsoft.kiota.authentication.KeyLocation; +### API keys — your backend calling as itself +An API key represents your organisation. Use it for work your own systems do: +importing a catalogue, running a nightly report, reconciling invoices. Keep it +out of source control and read it from the environment: + +```java var auth = new ApiKeyAuthenticationProvider( - "YOUR_RIXL_API_KEY", "X-API-Key", KeyLocation.Header); + System.getenv("RIXL_API_KEY"), "X-API-Key", ApiKeyLocation.HEADER); ``` -Bearer token: implement `AccessTokenProvider`, pass to `new BaseBearerTokenAuthenticationProvider(tokenProvider)`. +The key travels as the `X-API-Key` header. Anyone holding it can do anything +your organisation can, so it belongs on a server — never in a browser, a mobile +app, or anything you ship to users. + +### Client credentials — acting on behalf of one of your users + +If you are building on top of Rixl and your own users each need their own slice +of it, use client credentials. You exchange a client ID and secret for a +short-lived token scoped to a single end user, so one customer can never read +another's media. + +Create the credential with an API-key client. The secret comes back once: + +```java +import com.rixl.sdk.models.clientauth.v1.CreateClientCredentialRequest; + +var request = new CreateClientCredentialRequest(); +request.setName("Production backend"); +request.setOrgId(orgId); + +var created = client.platform().clientauth().v1().credentials().post(request); +System.out.println(created.getCredential().getClientId()); +System.out.println(created.getClientSecret()); +``` -## Feeds +Then mint a token per user. `subject` is your own identifier for that person — +whatever your database calls them: ```java -var posts = client.feeds().byFeedId("FD4y3QB38S").get(); -posts.getData().forEach(p -> System.out.println(p.getId())); +import com.rixl.sdk.models.clientauth.v1.MintClientTokenRequest; + +var mint = new MintClientTokenRequest(); +mint.setClientId(System.getenv("RIXL_CLIENT_ID")); +mint.setClientSecret(System.getenv("RIXL_CLIENT_SECRET")); +mint.setSubject(user.getId()); +mint.setProjectId(System.getenv("RIXL_PROJECT_ID")); +mint.setTtlMinutes(15); + +var token = client.platform().clientauth().v1().token().post(mint); ``` -## Images +Tokens last at most 15 minutes and there is no refresh token — when one expires +you mint another. Nothing in the SDK does that for you, so wrap the mint call in +an `AccessTokenProvider` and let the bearer provider ask for a token whenever it +needs one: ```java -var page = client.images().get(); -var image = client.images().byImageId("PS5IMKoFLm").get(); -client.images().byImageId("PS5IMKoFLm").delete(); +import com.microsoft.kiota.authentication.BaseBearerTokenAuthenticationProvider; + +var bearer = new BaseBearerTokenAuthenticationProvider(tokenProvider); +var userAdapter = new OkHttpRequestAdapter(bearer); +userAdapter.setBaseUrl("https://api.rixl.com"); +var userClient = new RixlClient(userAdapter); ``` -Upload (init → PUT bytes → complete): +`AccessTokenProvider` has two methods. `getAuthorizationToken(URI, Map)` is +where you return `getAccessToken()` from a mint call and cache it until +`getExpiresAt()`, and `getAllowedHostsValidator()` can return +`new AllowedHostsValidator()` to allow every host. Tokens go out as +`Authorization: Bearer`. + +Credentials are managed through the same builder you created them with: +`credentials().get()` lists them and +`credentials().byCredential_id(id).revoke().post()` kills one. Revoking stops +new tokens immediately; ones already issued die within 15 minutes. + +### Public endpoints + +Some reads need no credentials at all — fetching a public image or video, +reading a public feed, listing supported languages, and the sign-in flows under +`/auth/v1`. Point an anonymous provider at those: ```java -import com.rixl.sdk.models.internal_images_handler.UploadInitRequest; -import com.rixl.sdk.models.internal_images_handler.CompleteRequest; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.URI; +import com.microsoft.kiota.authentication.AnonymousAuthenticationProvider; + +var adapter = new OkHttpRequestAdapter(new AnonymousAuthenticationProvider()); +adapter.setBaseUrl("https://api.rixl.com"); +var client = new RixlClient(adapter); + +var image = client.media().v1().images().byImage_id(imageId).get(); +var languages = client.media().v1().languages().get(); +var feed = client.posts().v1().feeds().byFeed_id(feedId).get(); +``` + +Mind the difference between the two image paths: `media().v1().images()` is the +public read, while `media().v1().projects().byProject_id(id).images()` is the +authenticated collection you list, upload to and delete from. + +## What you can do + +Every area of the API is a method on the client, and the path you type mirrors +the URL. + +**Media** — `client.media().v1()`. `images()` and `videos()` for public reads, +and `projects().byProject_id(id)` for everything else: listing, uploading, +deleting, visibility, plus `audioTracks()`, `chapters()` and `subtitles()` on a +video. `languages()` lists what you can localise into. -var initReq = new UploadInitRequest(); -initReq.setName("photo.jpg"); -initReq.setFormat("jpeg"); -var initRes = client.images().upload().init().post(initReq); - -var http = HttpClient.newHttpClient(); -var put = HttpRequest.newBuilder(URI.create(initRes.getPresignedUrl())) - .header("Content-Type", "image/jpeg") - .PUT(HttpRequest.BodyPublishers.ofByteArray(imageBytes)) - .build(); -http.send(put, HttpResponse.BodyHandlers.discarding()); - -var completeReq = new CompleteRequest(); -completeReq.setImageId(initRes.getImageId()); -completeReq.setAttachedToVideo(false); -var image = client.images().upload().complete().post(completeReq); +**Content** — `client.posts().v1()` for posts and feeds, +`client.feeds().v1().projects().byProject_id(id).feeds()` for feed +configuration, and `client.organizations().byOrg_id(id).projects()` for the +projects everything else hangs off. That is why so many calls take a project ID. + +**Analytics** — `client.analytics().v1()`: `dashboard()`, `events()`, `posts()`, +`videos()`, `feeds()`, `funnels()`, `retention()`, `realtime()`, `top()`. Track +events and read back engagement, playback and live activity. + +**Billing** — `client.billing().v1()`: `plans()`, `subscription()`, +`invoices()`, `paymentMethods()`, `checkout()`, `storageUsage()`, +`bandwidthUsage()`, `tax()`, `address()`. + +**Accounts** — `client.auth().v1()`: `register()`, `login()`, `token()`, +`users()`, `passkey()`, `password()`, `providers()`, `memberships()`, +`policies()`, `email()`, `blog()`. Sign-in flows including passkeys and TOTP, +organisation membership and roles, and transactional email. + +**Platform** — `client.platform()` for `auth().v1()` and `clientauth().v1()`, +and `client.organizations().byOrg_id(id).apiKeys()` for API keys. + +`client.internal()` is storage-callback plumbing that Rixl calls itself. You +should not need it. + +## Working with resources + +Builders compose the same way everywhere, so once you have used one you have +used all of them. Reads and deletes: + +```java +var images = client.media().v1().projects().byProject_id(projectId).images(); + +var page = images.get(); +images.byImage_id(imageId).delete(); ``` -## Videos +Calls that send data take a generated body class with setters: ```java -var videos = client.videos().get(); -var video = client.videos().byVideoId("VI9VXQxWXQ").get(); -var tracks = client.videos().byVideoId("VI9VXQxWXQ").subtitles().get(); +import com.rixl.sdk.media.v1.projects.item.images.upload.UploadPostRequestBody; + +var body = new UploadPostRequestBody(); +body.setName("photo.jpg"); +body.setProjectId(projectId); + +var upload = images.upload().post(body); ``` -Upload returns presigned URLs for both the video and a poster image: +Everything is nullable. A field you never set is left out of the request rather +than sent empty, and a field the API omits comes back as `null` — check before +you dereference. + +## Uploading files + +Uploads happen in two steps. You ask Rixl for a URL, then send the bytes +straight to storage — they never pass through the API, so large files stay fast: ```java -import com.rixl.sdk.models.VideoUploadInitRequest; -import com.rixl.sdk.models.github_com_rixlhq_api_internal_videos_handler_upload.CompleteRequest; - -var initReq = new VideoUploadInitRequest(); -initReq.setFileName("clip.mp4"); -initReq.setImageFormat("jpeg"); -var initRes = client.videos().upload().init().post(initReq); -// PUT bytes to initRes.getVideoPresignedUrl() and initRes.getPosterPresignedUrl() - -var completeReq = new CompleteRequest(); -completeReq.setVideoId(initRes.getVideoId()); -var done = client.videos().upload().complete().post(completeReq); +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; + +var upload = client.media().v1().projects().byProject_id(projectId) + .images().upload().post(body); + +var put = HttpRequest.newBuilder(URI.create(upload.getUploadUrl())) + .header("Content-Type", "image/jpeg") + .PUT(HttpRequest.BodyPublishers.ofFile(Path.of("photo.jpg"))) + .build(); + +HttpClient.newHttpClient().send(put, HttpResponse.BodyHandlers.discarding()); ``` +Videos work the same way through `videos().upload()`, except the response gives +you two URLs — `getVideoUploadUrl()` for the file and `getPosterUploadUrl()` for +its poster image. + +There is no "finish" call to make. Storage tells Rixl when the object lands and +the image or video becomes available on its own. + ## Pagination +List calls take a limit and an offset, set through the request configuration: + ```java -int limit = 50, offset = 0; +int limit = 50; +int offset = 0; + while (true) { - var page = client.images().get(config -> { - config.queryParameters.limit = limit; - config.queryParameters.offset = offset; + final int currentOffset = offset; + var page = images.get(config -> { + config.queryParameters.paginationLimit = limit; + config.queryParameters.paginationOffset = currentOffset; }); - if (offset + page.getData().size() >= page.getPagination().getTotal()) break; + + if (page.getImages() == null || page.getImages().isEmpty()) { + break; + } + page.getImages().forEach(image -> System.out.println(image.getId())); + offset += limit; } ``` -## Errors +Nothing pages for you — ask for the next offset yourself. Responses also carry +`getTotal()`, but it deserialises as an untyped node rather than a number, so +stopping on a short page is the simpler test. + +## Handling errors + +Anything that is not a 2xx is thrown as `com.microsoft.kiota.ApiException`, +which carries the status code and the response headers: ```java -import com.rixl.sdk.models.github_com_rixlhq_api_internal_errors.ErrorResponse; +import com.microsoft.kiota.ApiException; try { - var image = client.images().byImageId("PS5IMKoFLm").get(); -} catch (ErrorResponse e) { - System.err.printf("HTTP %d: %s%n", e.getCode(), e.getError()); + var image = client.media().v1().images().byImage_id(imageId).get(); +} catch (ApiException e) { + System.err.println("rixl returned " + e.getResponseStatusCode()); throw e; } ``` -## Issues +What the codes mean: + +| Status | What happened | What to do | +| --- | --- | --- | +| 400 | The request was malformed or failed validation | Fix the request; retrying will not help | +| 401 | The key or token is missing, expired or invalid | Check the credential | +| 403 | The credential is valid but not allowed to do this | Check what it is permitted to do | +| 404 | No such resource, or it belongs to another organisation | Check the ID and the project | +| 429 | You are going too fast | Back off and retry | +| 5xx | Something broke on our side | Retry with backoff | + +Connection failures and timeouts surface as ordinary `IOException`s from OkHttp, +not as `ApiException`. + +## Timeouts + +Requests go out through whatever OkHttp client you hand the adapter, so timeouts +stay yours to control: + +```java +import java.time.Duration; +import okhttp3.OkHttpClient; + +var http = new OkHttpClient.Builder() + .callTimeout(Duration.ofSeconds(30)) + .build(); + +var adapter = new OkHttpRequestAdapter(auth, null, null, http); +adapter.setBaseUrl("https://api.rixl.com"); +``` + +The same hook is where an interceptor goes if you want tracing headers on every +outbound request. + +## Versioning + +This package follows [SemVer](https://semver.org/spec/v2.0.0.html). New API +resources arrive in minor releases; renamed or removed operations only in major +ones. If an upgrade breaks you unexpectedly, please open an issue — we would +rather hear about it. + +## Support -[github.com/rixlhq/rixl-java/issues](https://github.com/rixlhq/rixl-java/issues) +Bugs and feature requests: +[github.com/rixlhq/rixl-java/issues](https://github.com/rixlhq/rixl-java/issues). From 9a3c51d60f692f0a1941a9e8e8a7c25bb8ea8251 Mon Sep 17 00:00:00 2001 From: Yusuf Akinleye Date: Mon, 3 Aug 2026 18:22:00 +0100 Subject: [PATCH 2/2] docs: simplify punctuation and group resources by area --- CONTRIBUTING.md | 4 ++-- README.md | 40 ++++++++++++++++++++-------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80b03ec..f4a0ba6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Everything under `sdk/` is generated. Do not edit it by hand — the next +Everything under `sdk/` is generated. Do not edit it by hand, because the next regeneration will overwrite your changes. Fix the OpenAPI spec instead, then regenerate. @@ -43,5 +43,5 @@ Java 17. Releases are cut by release-please from conventional commits. The version lives in `pom.xml` and `.release-please-manifest.json`, and both are updated by the -release PR — do not bump them by hand. When the version changes, update the +release PR, so do not bump them by hand. When the version changes, update the install snippets in `README.md` to match. diff --git a/README.md b/README.md index 1340976..da067d5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The official Java client for the [Rixl](https://rixl.com) API. -Rixl handles the media side of your product — uploading and delivering images +Rixl handles the media side of your product: uploading and delivering images and videos, organising them into feeds and posts, and reporting on how people engage with them. It also covers the account layer around that: users and organisations, sign-in, subscriptions and invoices. This SDK gives you all of it @@ -29,7 +29,7 @@ Gradle: implementation("com.rixl:sdk:1.1.0") ``` -That pulls in the Kiota runtime the generated code is built on — the +That pulls in the Kiota runtime the generated code is built on: the abstractions, the OkHttp transport and the JSON, form, text and multipart serializers. @@ -64,7 +64,7 @@ public class Example { } ``` -The adapter has no base URL of its own, so `setBaseUrl` is not optional — call +The adapter has no base URL of its own, so `setBaseUrl` is not optional. Call it before you make a request, and point it somewhere else when you are testing against another environment. @@ -74,7 +74,7 @@ Calls are synchronous: each one returns the parsed response, or throws. There are two ways to identify yourself, and they answer different questions. -### API keys — your backend calling as itself +### API keys, for your backend calling as itself An API key represents your organisation. Use it for work your own systems do: importing a catalogue, running a nightly report, reconciling invoices. Keep it @@ -86,10 +86,10 @@ var auth = new ApiKeyAuthenticationProvider( ``` The key travels as the `X-API-Key` header. Anyone holding it can do anything -your organisation can, so it belongs on a server — never in a browser, a mobile +your organisation can, so it belongs on a server. Never put one in a browser, a mobile app, or anything you ship to users. -### Client credentials — acting on behalf of one of your users +### Client credentials, for acting on behalf of your users If you are building on top of Rixl and your own users each need their own slice of it, use client credentials. You exchange a client ID and secret for a @@ -110,7 +110,7 @@ System.out.println(created.getCredential().getClientId()); System.out.println(created.getClientSecret()); ``` -Then mint a token per user. `subject` is your own identifier for that person — +Then mint a token per user. `subject` is your own identifier for that person, whatever your database calls them: ```java @@ -126,7 +126,7 @@ mint.setTtlMinutes(15); var token = client.platform().clientauth().v1().token().post(mint); ``` -Tokens last at most 15 minutes and there is no refresh token — when one expires +Tokens last at most 15 minutes and there is no refresh token. When one expires you mint another. Nothing in the SDK does that for you, so wrap the mint call in an `AccessTokenProvider` and let the bearer provider ask for a token whenever it needs one: @@ -153,7 +153,7 @@ new tokens immediately; ones already issued die within 15 minutes. ### Public endpoints -Some reads need no credentials at all — fetching a public image or video, +Some reads need no credentials at all: fetching a public image or video, reading a public feed, listing supported languages, and the sign-in flows under `/auth/v1`. Point an anonymous provider at those: @@ -178,30 +178,30 @@ authenticated collection you list, upload to and delete from. Every area of the API is a method on the client, and the path you type mirrors the URL. -**Media** — `client.media().v1()`. `images()` and `videos()` for public reads, +**Media**: `client.media().v1()`. `images()` and `videos()` for public reads, and `projects().byProject_id(id)` for everything else: listing, uploading, deleting, visibility, plus `audioTracks()`, `chapters()` and `subtitles()` on a video. `languages()` lists what you can localise into. -**Content** — `client.posts().v1()` for posts and feeds, +**Content**: `client.posts().v1()` for posts and feeds, `client.feeds().v1().projects().byProject_id(id).feeds()` for feed configuration, and `client.organizations().byOrg_id(id).projects()` for the projects everything else hangs off. That is why so many calls take a project ID. -**Analytics** — `client.analytics().v1()`: `dashboard()`, `events()`, `posts()`, +**Analytics**: `client.analytics().v1()`: `dashboard()`, `events()`, `posts()`, `videos()`, `feeds()`, `funnels()`, `retention()`, `realtime()`, `top()`. Track events and read back engagement, playback and live activity. -**Billing** — `client.billing().v1()`: `plans()`, `subscription()`, +**Billing**: `client.billing().v1()`: `plans()`, `subscription()`, `invoices()`, `paymentMethods()`, `checkout()`, `storageUsage()`, `bandwidthUsage()`, `tax()`, `address()`. -**Accounts** — `client.auth().v1()`: `register()`, `login()`, `token()`, +**Account management**: `client.auth().v1()`: `register()`, `login()`, `token()`, `users()`, `passkey()`, `password()`, `providers()`, `memberships()`, `policies()`, `email()`, `blog()`. Sign-in flows including passkeys and TOTP, organisation membership and roles, and transactional email. -**Platform** — `client.platform()` for `auth().v1()` and `clientauth().v1()`, +**Platform**: `client.platform()` for `auth().v1()` and `clientauth().v1()`, and `client.organizations().byOrg_id(id).apiKeys()` for API keys. `client.internal()` is storage-callback plumbing that Rixl calls itself. You @@ -232,13 +232,13 @@ var upload = images.upload().post(body); ``` Everything is nullable. A field you never set is left out of the request rather -than sent empty, and a field the API omits comes back as `null` — check before +than sent empty, and a field the API omits comes back as `null`, so check before you dereference. ## Uploading files Uploads happen in two steps. You ask Rixl for a URL, then send the bytes -straight to storage — they never pass through the API, so large files stay fast: +straight to storage. The bytes never pass through the API, so large files stay fast: ```java import java.net.URI; @@ -259,7 +259,7 @@ HttpClient.newHttpClient().send(put, HttpResponse.BodyHandlers.discarding()); ``` Videos work the same way through `videos().upload()`, except the response gives -you two URLs — `getVideoUploadUrl()` for the file and `getPosterUploadUrl()` for +you two URLs: `getVideoUploadUrl()` for the file and `getPosterUploadUrl()` for its poster image. There is no "finish" call to make. Storage tells Rixl when the object lands and @@ -289,7 +289,7 @@ while (true) { } ``` -Nothing pages for you — ask for the next offset yourself. Responses also carry +Nothing pages for you. Ask for the next offset yourself. Responses also carry `getTotal()`, but it deserialises as an untyped node rather than a number, so stopping on a short page is the simpler test. @@ -347,7 +347,7 @@ outbound request. This package follows [SemVer](https://semver.org/spec/v2.0.0.html). New API resources arrive in minor releases; renamed or removed operations only in major -ones. If an upgrade breaks you unexpectedly, please open an issue — we would +ones. If an upgrade breaks you unexpectedly, please open an issue. We would rather hear about it. ## Support