@api.global/swiftsupport

Swift for api.global servers: a transport that speaks @api.global/typedrequest 9 over HTTP and @api.global/typedsocket 9 over WebSocket, and a generator that turns the servers' TypeScript contracts into Swift Codable types, so a Swift app calls client.fire(.listInvoices, request) checked at compile time instead of hand-writing envelopes and DTOs.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

The Swift package (Swift 6, iOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1, Linux):

dependencies: [
  .package(url: "https://code.foss.global/api.global/swiftsupport.git", from: "1.1.0"),
],
targets: [
  .target(name: "MyKit", dependencies: [.product(name: "SwiftSupport", package: "swiftsupport")]),
]

Releases are tagged vX.Y.Z, which SwiftPM reads as version X.Y.Z.

From 1.2.1 every release is also in the Swift package registry of code.foss.global as apiglobal.swiftsupport, readable without a sign-in. Map the scope once, for the project or with --global for your user:

swift package-registry set --scope apiglobal https://code.foss.global/api/packages/api.global/swift
dependencies: [
  .package(id: "apiglobal.swiftsupport", from: "1.2.1"),
],
targets: [
  .target(name: "MyKit", dependencies: [.product(name: "SwiftSupport", package: "apiglobal.swiftsupport")]),
]

The archives are not signed, so SwiftPM warns on download.

The generator, where the contracts are built:

pnpm add --save-dev @api.global/swiftsupport

Contracts

A contract is a value of TypedRequestContract: its wire name and its request and response types. The generator writes them; written by hand they look the same:

public struct ListInvoices: TypedRequestContract {
    public static let method = "listInvoices"
    public struct Request: Codable, Hashable, Sendable { public var organizationId: String }
    public typealias Response = PageInvoice
    public init() {}
}

extension TypedRequestContract where Self == ListInvoices {
    public static var listInvoices: ListInvoices { ListInvoices() }
}

client.fire(.listInvoices, .init(organizationId: id)) then infers the request and response types from the contract; another contract's request does not compile.

HTTP: TypedRequestClient

let client = TypedRequestClient(
    baseURL: URL(string: "https://example.com")!,
    path: "apprequest",                       // default: "typedrequest"
    timeout: .seconds(30),                    // default: none beyond URLSession's own
    headers: { ["Authorization": "Bearer \(try await credentials.secret())"] }
)
let page = try await client.fire(.listInvoices, .init(organizationId: id))

Each call posts one TypedRequest 9 envelope (method, request, response: null, a fresh requestInstanceId, correlation) and accepts only the answer of exactly that request. The header provider runs once per call; its headers stay through the server's retries. retry: { waitForMs } answers are followed up to maxRetries (default 3), each with a new request instance. Redirects are refused, so a credential header never follows one. A call ends with the contract's response or throws:

Error When
TypedRequestError the server answered with its error envelope: method, message (error.text), data (error.data as JSONValue), code (data.code where the server puts one), retry (a retry hint sent with the error), decodeData(_:) for a typed refusal
TypedTransportError.httpFailure an HTTP answer that is no envelope, such as a gateway's or a rate limiter's problem document: its status, headers and body
TypedTransportError.invalidResponse an answer of another request, or a payload of another shape than the contract's, with the path that failed
TypedTransportError.timedOut / .retryLimitExceeded the deadline passed; the server asked for more retries than allowed (an error the last retry hint came with is thrown as itself)
URLError the network failed, as URLSession reports it
CancellationError the calling task was cancelled

TypedRequestClient.decodeResponseEnvelope(.listInvoices, from: data) reads a recorded answer the same way, for fixture tests.

WebSocket: TypedSocketClient

var configuration = TypedSocketClient.Configuration()
configuration.prepareConnection = { _ in ["Authorization": "Bearer \(try await credentials.secret())"] }
configuration.restoreConnection = { context in
    _ = try await context.fire(.resubscribe, .init(topics: topics))
}
let socket = try TypedSocketClient(serverURL: URL(string: "https://example.com/typedsocket")!, configuration: configuration)

await socket.handle(.invoiceChanged) { change in   // requests the server sends
    await store.apply(change)
    return .init()
}
try await socket.connect()
let page = try await socket.fire(.listInvoices, .init(organizationId: id))
try await socket.setTag("workspace", payload: ["workspaceId": id])
for await status in await socket.statusUpdates() { … }
await socket.stop()

The client speaks TypedSocket 9 exactly:

  • envelopes travel as text frames, each with its own requestInstanceId;
  • the first frame is the package-major handshake (__typedsocket_checkVersion, major 9, typedrequest-cancellation-v1), answered key by key; a server of another major, or one that refuses the handshake (close 1008), ends the client for good (TypedTransportError.handshakeFailed), while a handshake that merely did not finish reconnects;
  • prepareConnection supplies the headers of every upgrade; restoreConnection runs on every new connection after the handshake, then the client's tags (__typedsocket_setTag) are set again, and only then is the client connected; a TypedSocketRestoreDeferral defers the attempt without using up a retry, a transport failure retries it, any other error ends the client;
  • a request that times out (default 30 s, including the wait for a connection) or whose task is cancelled is cancelled on the server with __typedsocket_cancelRequest; a request the server cancels cancels its handler's task, and is not answered;
  • a handler that throws TypedRequestError answers with that error; any other error answers Internal server error, a method without handler the TypedRouter's own refusal;
  • a lost connection reconnects with exponential backoff (1 s doubling to 60 s, 20 % jitter, 100 attempts, reset by a successful connection); requests in flight fail with TypedTransportError.connectionLost, since they may have arrived.

Plain ws:/http: is allowed to loopback hosts only, as in TypedSocket. VirtualStreams (binary frames) are not supported.

Generating contracts

pnpm exec swiftsupport generate \
  --tsconfig tsconfig.json \
  --contracts 'ts_interfaces/requests/*.ts' \
  --out Sources/MyKit/Contracts.generated.swift \
  --type TCents=Int

or with a configuration file, whose paths are relative to it:

{
  "tsconfig": "../mainapp/tsconfig.json",
  "contracts": "../mainapp/ts_interfaces/requests/*.ts",
  "out": "Sources/MyKit/Contracts.generated.swift",
  "methods": ["listInvoices", "getInvoice"],
  "typeOverrides": { "TCents": "Int" },
  "renames": { "IText": "MessageText" },
  "omit": ["csrfToken"]
}
pnpm exec swiftsupport generate --config swiftsupport.config.json          # writes the file
pnpm exec swiftsupport generate --config swiftsupport.config.json --check  # exit 1 when it is out of date

The contracts are read by @api.global/typedopenapi, which turns every export named like a contract (IReq_…, --contract-pattern) into JSON Schema; the generator writes Swift from that schema. The output depends on the contracts and the options alone, so --check makes a drift gate in CI.

TypeScript Swift
interface IInvoice { … } public struct Invoice: Codable, Hashable, Sendable with a memberwise init; the I/T prefix is dropped where a word follows
note?: string var note: String?, left out when nil
dueDate: string | null var dueDate: String?, written as null when nil
Required<T>, NonNullable<T> every member required, so not optional (one that holds null stays optional and is written as null); T without null and undefined
kind: 'invoice' var kind: String = "invoice", checked when read
'draft' | 'issued' an OpenStringEnum with case draft, issued and case unknown(String) for values this build does not know; knownCases lists the others
{ kind: 'a'; … } | { kind: 'b'; … } an enum with a case per kind and case unknown(JSONValue); the discriminator may be absent in one branch, take several values in another, or tell apart branches that are unions themselves
true/false discriminators an enum with a case per branch
unions without a discriminator an enum that takes the first case that decodes, objects that require the most first; inline objects are named by what only they require (.on, .fromAndTo)
T[], Record<string, T> [T], [String: T]
inline objects and literal unions types nested in the type that holds them: Invoice.Customer
number, @asType integer Double, Int; --type TCents=Int maps a named type
IPage<IInvoice> PageInvoice
unknown, any JSONValue
undefined, never members left out
{} request or response, Record<string, never> TypedRequestVoid, which also reads null
JSDoc, @deprecated doc comments; a deprecated contract's static member is @available(*, deprecated)
the contract IReq_ListInvoices (or IRequest_ListInvoices) struct ListInvoices: TypedRequestContract, its inline request and response nested as Request and Response, and the static member .listInvoices

omit leaves members out of every struct by their JSON key, as typedopenapi's routes omit them: a bearer client drops the browser's csrfToken, which the contracts declare on every request, so it neither builds nor sends one. A struct whose every member is omitted is an object without members.

Names that would shadow the standard library, Foundation, SwiftUI or SwiftSupport (Data, Text, Request …) keep their TypeScript name; renames names any type. A type that would hold itself by value (not through a list or an optional union) is refused. Shapes JSON Schema cannot carry into Swift (tuples, mixed enums) become JSONValue with a warning.

The library form is generateSwiftContracts(options), which returns the source, the methods and types generated, and the warnings.

Development

pnpm install        # also the TypedRequest 9 / TypedSocket 9 server the Swift tests run against
pnpm test           # the generator's tests
pnpm test:swift     # swift test: the transport against Tests/Fixtures/typedserver.mjs, and the generated fixture
pnpm generate:fixture

The Swift tests start Tests/Fixtures/typedserver.mjs with node (on PATH) for a real TypedRequest 9 HTTP endpoint and TypedSocket 9 server; without node or the installed dependencies those suites are skipped. Tests/SwiftSupportTests/Generated/FixtureContracts.swift is generated from test/fixtures/contracts; the node tests fail while it differs from what generation gives.

A pushed release tag runs .gitea/workflows/swift-registry.yml: scripts/publish-swift-registry.mjs uploads git archive of the tag to the registry, then reads it back without a sign-in and compares checksums. A version the registry already holds passes only with the same archive. The job token may not write packages, so the upload uses the organisation's Actions secret SWIFT_REGISTRY_TOKEN, an access token with package write scope.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

S
Description
No description provided
Readme
718 KiB
Languages
Swift 63.3%
TypeScript 30.6%
JavaScript 6.1%