Skip to content

DocsGet started

Swift SDK

The iOS and macOS client: upload in the foreground or in a background session with a client token or a one-time URL, follow a job, and refresh the token through your backend.

The Swift SDK is for iOS 16 and macOS 13 or newer, and has no dependencies. An app uploads a file straight to Videxo and follows the job your backend created. It never holds an API key: it uses a client token your backend minted, or a one-time upload URL your backend made. Jobs are always created by your backend, with its key.

Install#

Add the package with Swift Package Manager, from github.com/videxo-io/videxo-swift (opens in a new tab):

Swift
// Package.swift
.package(url: "https://github.com/videxo-io/videxo-swift", from: "0.1.0")
// and in your target's dependencies:
.product(name: "Videxo", package: "videxo-swift")

In Xcode, choose File, then Add Package Dependencies, and enter the same address. Without the package, iOS with Swift does the same with URLSession and your backend.

What is available#

WhatToday
UploadsA file, up to the upload limit, with progress, in the foreground or a background session.
Pause and resumeOf an upload that is still connected.
JobsRead, list and wait, through a client token, with a refresh through your backend.
Resumable uploads SoonPlanned, not available yet: picking up after a lost connection, and files past 100 MB. Until then a failed upload needs a new upload.

Upload with a client token#

Your backend mints a token (Client tokens) and hands the app its token and id. The app holds them in memory:

Swift
import Videxo

let videxo = try VidexoClient(token: minted.token, tokenId: minted.id)
let completed = try await videxo.uploads.upload(file: fileURL) { progress in
    print(progress.fraction)
}
// Send completed.inputURL to your backend, which creates the job with its API key.

uploads.upload creates the upload from the file's size, a plain form of its name and the content type of its extension, unless you name them, and then sends the bytes. The creation sends an Idempotency-Key of its own and retries, so a lost answer spends nothing of the token's max_count.

The constructor throws invalid_argument for a value shaped like an API key, and for anything else that is not a client token.

Upload with a URL from your backend#

No token is needed when your backend made the upload with POST /v1/uploads:

Swift
let completed = try await VidexoUpload().send(file: fileURL, to: uploadURL, contentType: "video/mp4") { progress in
    print(progress.fraction)
}

The upload URL takes one PUT, fully sent within one hour, so the SDK never retries it. After a failed PUT, create a new upload (Files). Cancelling the calling task ends the upload. For pause and resume, use start, which answers a handle:

Swift
let handle = try VidexoUpload().start(file: fileURL, to: uploadURL)
handle.pause()
handle.resume()
let completed = try await handle.value

Pausing stops sending and keeps the connection. When the connection ends while paused, the upload fails and the next try needs a new upload.

Upload in the background#

BackgroundUploader sends the file through a background URLSession: the system goes on when the app is suspended, and wakes it with the result. Make it once, at launch, so that the results of uploads started before a relaunch find it. Each event comes on a background queue. Background upload has not yet been tested on a device: so far it has only run on macOS while the process stays alive, not after an app is suspended or relaunched.

examples/swift/VidexoTokenExample/TokenFlow.swift · SDK
private(set) lazy var uploader = BackgroundUploader(identifier: "com.example.videxo.uploads") { [unowned self] event in
    switch event {
    case let .progress(upload):
        self.progress(upload.progress.fraction)
    case let .finished(upload, result):
        // The system no longer needs the file. The tag names the copy this app made.
        if let tag = upload.tag, let copy = try? Uploads.copy(named: tag) { try? FileManager.default.removeItem(at: copy) }
        self.finish(upload.id, result)
    }
}

Pass the system's completion handler on:

examples/swift/VidexoTokenExample/VidexoTokenExampleApp.swift · SDK
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
    Uploads.shared.uploader.handleEvents(completionHandler: completionHandler)
}

Then create the upload with the token and start it:

Swift
let upload = try await videxo.uploads.create(forFile: fileInYourContainer)
let started = try uploader.start(file: fileInYourContainer, to: upload.uploadURL, tag: "order-7")
  • The file must stay in your app's container until finished. A file from a picker is outside it, so copy it first.
  • An upload keeps its id and your tag after a relaunch, and uploads() lists those under way. finished is told once, so keep its result. Never put a token in the tag: it is stored on the device.
  • Progress while the bytes go out comes when the system reports it, which may be never for a small or fast upload. An upload that succeeds always gets a last progress with loaded equal to total, right before finished. The same goes for the onProgress of VidexoUpload.
  • The upload goes on when its token is refreshed or expires. Revoking the chain or the key that minted the token refuses a PUT that has not started. It does not interrupt one under way.
  • The system may repeat a PUT after a lost connection. The upload URL refuses a second one with 409 conflict (upload_used), and that is the result: create a new upload.
  • The uploader gives the system one hour, the upload URL's own limit. allowsCellularAccess and isDiscretionary are options of its constructor.

Follow a job#

Your backend creates the job from the upload's input_url, and answers the app with the job's id and a continuation of the token that adds the job (Follow a job). Pass getToken, and the client asks your backend for the next token when the current one is about to expire or is refused as expired or superseded:

examples/swift/VidexoTokenExample/TokenFlow.swift · SDK
/// Uploads `file` and follows the job your backend creates from it. `file` is from a picker, so it
/// is copied first: the system reads it while the app is suspended.
func run(file: URL, onStatus: @Sendable @escaping (String) -> Void) async throws -> ClientJob {
    let minted = try await backend.uploadToken()
    // `getToken` refreshes a token when it is about to end, so a long flow keeps its uploads and jobs.
    let getToken: VidexoClient.GetToken = { [backend] request in try await backend.refresh(tokenId: request.tokenId) }
    let videxo = try VidexoClient(token: minted.token, tokenId: minted.id, baseURL: apiURL, getToken: getToken)

    onStatus("Uploading")
    let copy = try copyIntoContainer(file)
    let upload = try await videxo.uploads.create(forFile: copy)
    let started = try Uploads.shared.uploader.start(file: copy, to: upload.uploadURL, tag: copy.lastPathComponent)
    let completed = try await Uploads.shared.completion(of: started.id)

    // Only your backend creates the job, and answers a continuation that may read it. The token cannot.
    onStatus("Creating the job")
    let job = try await backend.createJob(continues: minted.id, inputURL: completed.inputURL)
    let follower = try VidexoClient(token: job.token, tokenId: job.id, baseURL: apiURL, getToken: getToken)
    onStatus("Job \(job.jobId): running")
    return try await follower.jobs.wait(job.jobId)
}

Concurrent calls share one refresh, and a failed refresh is asked again with the same id on the next call. Without getToken, the refusal comes back as an error. Your backend's continuation must name what the token keeps: read job ids carry over, but a token that only uploads names its uploads section again, or minting answers 400.

jobs.wait reads the job every 2 seconds, for at most 10 minutes unless you say otherwise. It answers a failed job rather than throwing it, waits out a 429 by its Retry-After, throws wait_timeout when the job has not ended in time, and aborted when the calling task is cancelled. Each output has a signed downloadURL that needs no token. jobs.get, jobs.list and token() read one job, the token's jobs, and what the token may still do.

Errors#

Every failure is a VidexoError. Programs branch on code, and on reason for the finer case:

Swift
do {
    _ = try await videxo.uploads.upload(file: fileURL)
} catch let error as VidexoError where error.code == .limitExceeded && error.reason == "token_uploads" {
    // Your backend's grant is used up: ask it for a new token.
} catch let error as VidexoError {
    print(error.detail, error.requestId ?? "")
}
PropertyMeaning
statusThe HTTP status; nil when no answer came.
codeThe stable code: the Errors page, or one of the SDK's own below.
reasonThe documented finer case of the code; nil without one.
title, detailThe code's fixed sentence, and what happened this time, for people.
typeA link to the code on the Errors page; nil for the SDK's own codes.
errorsWith invalid_request: one entry per field that failed.
retryableThe API's hint whether the same request can succeed later; nil for the SDK's own codes.
requestIdQuote it to support. Nil when no answer came.
retryAfterSeconds from a Retry-After header; nil without one.
bodyThe answer's body as received; nil when no answer came.

The text of an error never holds a token or an upload URL. The SDK's own codes have status nil, except http_error and invalid_response:

CodeWhen
network_errorThe request did not get an answer.
timeoutNo answer within the timeout.
abortedThe calling task was cancelled, or the upload was.
wait_timeoutjobs.wait gave up before the job ended.
http_errorAn error answer that is not problem details.
invalid_responseA successful answer the SDK could not read.
invalid_argumentA value refused before sending: an API key, or a file it cannot read.

Retries and timeouts#

Calls with a token are retried up to maxRetries times, 2 by default, after a 429, 502, 503, 504, a timeout or a network error. The wait follows Retry-After up to 60 seconds, and otherwise doubles from 0.5 to 8 seconds with jitter. timeout, 60 seconds by default, is per attempt. An upload's PUT is never retried.

VidexoClient optionDefaultMeaning
tokenrequiredA client token your backend minted.
tokenIdread on first useThe token's id. Pass it: a token that has already expired can no longer tell it.
baseURLhttps://api.videxo.ioThe API's address.
transportURLSession.sharedWhat sends the calls, not the bytes of an upload.
maxRetries2Retries of a failed call.
timeout60Seconds one attempt may take; nil for none.
getTokennoneAsks your backend for a continuation of the token.

A whole app#

The iOS example holds the backend flow in VidexoExample/ and this one in VidexoTokenExample/. The app asks your backend for its tokens:

examples/swift/VidexoTokenExample/TokenBackend.swift · SDK
/// A new client token for one upload.
func uploadToken() async throws -> MintedToken {
    try await post("api/client-tokens", [:])
}

/// The next token of the chain. `VidexoClient` asks for it when the current one is about to end.
func refresh(tokenId: String?) async throws -> FreshToken {
    guard let tokenId else { throw URLError(.userAuthenticationRequired) }
    let next: MintedToken = try await post("api/client-tokens", ["continues": tokenId])
    return FreshToken(token: next.token, id: next.id)
}

/// Your backend creates the job from the upload, with its API key, and answers a continuation that may read it.
func createJob(continues tokenId: String, inputURL: String) async throws -> FollowedJob {
    try await post("api/client-tokens/jobs", ["continues": tokenId, "input_url": inputURL])
}

The backend is the Express example, which mints them with its API key:

examples/express/src/videxo.ts · SDK
/** What an upload token may do: one upload of a video. It spends nothing, and it cannot create a job. */
const UPLOAD_GRANT = { max_count: 1, max_size_bytes: 100_000_000, content_types: ['video/*'] };

export function uploadToken(subject: string): Promise<ClientToken> {
  return call((videxo) => videxo.clientTokens.create({ subject, uploads: UPLOAD_GRANT }));
}

/** A continuation of the app's token that may read the job too. */
export function followToken(continues: string, jobId: string): Promise<ClientToken> {
  return call((videxo) => videxo.clientTokens.forJob(jobId, { continues }));
}

/**
 * The next token of the chain. A section left out is not granted, and only read job ids carry over, so
 * a token that is still for its upload names the grant again; the count stays the chain's.
 */
export function refreshToken(continues: string, following: boolean): Promise<ClientToken> {
  return call((videxo) => videxo.clientTokens.create(following ? { continues } : { continues, uploads: UPLOAD_GRANT }));
}
examples/express/src/app.ts · plain HTTP
// For an app that holds a client token instead of asking this server for each step.

/**
 * The signed-in user, from your own sign-in. This example has none, so it refuses everyone: replace
 * the lookup with your session or JWT check. VIDEXO_EXAMPLE_TEST_SESSIONS (JSON, a session to its
 * user) is there for the example's own test and for trying it locally; never set it in production.
 */
function currentUser(req: Request): string {
  const sessions = JSON.parse(process.env.VIDEXO_EXAMPLE_TEST_SESSIONS ?? '{}') as Record<string, string>;
  const session = /^Bearer (.+)$/.exec(req.get('authorization') ?? '')?.[1];
  if (session && Object.hasOwn(sessions, session)) return sessions[session]!;
  throw new VidexoProblem({
    type: 'about:blank',
    title: 'Sign in first.',
    status: 401,
    detail: 'This route needs a signed-in user.',
    code: 'authentication_failed',
  });
}

/** The chains of tokens this server minted, by token id. Keep them in your database, so they outlive a restart. */
type Chain = { user: string; following: boolean };
const chains = new Map<string, Chain>();

/** The chain of a token id, only for the user it was minted for; any other answer is the same as an unknown id. */
function chainOf(tokenId: unknown, user: string): Chain {
  const chain = typeof tokenId === 'string' ? chains.get(tokenId) : undefined;
  if (chain?.user === user) return chain;
  throw new VidexoProblem({ type: 'about:blank', title: 'Not found.', status: 404, detail: 'No such client token.', code: 'not_found' });
}

// No `continues`: a new upload token for this user. With it: the next token of the user's own chain.
app.post('/api/client-tokens', async (req, res) => {
  const user = currentUser(req);
  const { continues } = req.body ?? {};
  const chain = continues === undefined ? { user, following: false } : chainOf(continues, user);
  const minted = continues === undefined ? await uploadToken(user) : await refreshToken(continues, chain.following);
  chains.set(minted.id, chain);
  res.status(201).json({ id: minted.id, token: minted.token, expires_at: minted.expires_at });
});

// Creates the job from the user's upload, and answers its id with a continuation that may read it. The
// app never names a job: this server made this one, so it is the user's.
app.post('/api/client-tokens/jobs', async (req, res) => {
  const chain = chainOf(req.body?.continues, currentUser(req));
  const { continues, input_url } = req.body;
  const upload = typeof input_url === 'string' ? UPLOAD.exec(input_url)?.[1] : undefined;
  if (!upload) throw badRequest('input_url must be the input_url of an upload.');
  const job = await transcode(input_url, `transcode-${upload}`);
  const minted = await followToken(continues, job.id);
  chain.following = true;
  chains.set(minted.id, chain);
  res.status(201).json({ job_id: job.id, id: minted.id, token: minted.token, expires_at: minted.expires_at });
});