Composable Atmosphere#
Type-safe, testable Swift tools for building AT Protocol clients, OAuth flows, and Composable Architecture applications.
WARNING
Composable Atmosphere is under active development. The package is already useful for AT Protocol identity, XRPC, OAuth, and app-auth experiments, but the public API is still settling.
Overview#
Composable Atmosphere brings Point-Free-style Swift ergonomics to the AT Protocol:
- Core AT Protocol identifiers such as DIDs, handles, AT URIs, NSIDs, CIDs, blobs, and PDS hosts are modeled as small Swift types instead of unstructured strings.
- Network clients are dependency clients, so identity resolution, XRPC calls, OAuth, and auth
presentation can be controlled in tests and previews with
withDependencies. - XRPC methods are represented as request values that declare their NSID, request type, input, parameters, output, routing, and authorization policy.
- OAuth support handles the pieces AT Protocol clients need in practice: handle/DID resolution, authorization-server discovery, PAR, PKCE, DPoP-bound tokens, callback handling, refresh, and permission escalation.
- The TCA integration wraps OAuth session state around authenticated features, installs default session dependencies, and exposes login/logout/session events to the feature tree.
The goal is not to hide the AT Protocol. It is to make protocol work feel like ordinary Swift app work: explicit values, typed boundaries, controllable dependencies, and focused tests.
Quick start#
Define an XRPC request by conforming a small value to XRPC.Request:
import ATProto
import XRPCClient
struct GetPosts: XRPC.Request {
typealias Input = Never
static let nsid: ATProto.NSID = "app.bsky.feed.getPosts"
static let type: XRPC.RequestType = .query
struct Parameters: Encodable {
var uris: [ATProto.URI]
}
struct Output: Decodable {
var posts: [Post]
}
var parameters: Parameters
}
struct Post: Decodable {
var uri: ATProto.URI
var author: Author
struct Author: Decodable {
var did: DID
}
}
Run it through the dependency-controlled XRPC client:
import Dependencies
let response = try await withDependencies {
$0.pdsHost = "public.api.bsky.app"
} operation: {
@Dependency(XRPC.Client.self) var xrpcClient
return try await xrpcClient.run(
GetPosts(
parameters: .init(
uris: [
ATProto.URI(
"at://did:plc:cqqtuv75m7e5s4uj6nw4czyu/app.bsky.feed.post/3mijxhhak2s2l"
)!
]
)
)
)
}
print(response.posts)
For authenticated methods, declare an authorization policy on the request:
struct CreatePost: XRPC.Request {
static let nsid: ATProto.NSID = "com.atproto.repo.createRecord"
static let type: XRPC.RequestType = .procedure
static let authorizationPolicy: XRPC.AuthorizationPolicy = .required(
ATProto.OAuth.AuthRequirement(
permissions: [
.repo("app.bsky.feed.post", actions: [.create])
],
reason: "Create posts"
)
)
// Input, Parameters, Output, and stored properties...
}
The live XRPC client asks the xrpcAuthorizationGate dependency how to satisfy that policy, which
lets apps decide whether to run unauthenticated, attach the current OAuth session, prompt for
sign-in, or request additional permissions.
Products#
The package is split into focused products so apps can adopt the pieces they need:
| Product | Purpose |
|---|---|
ATProto |
Core identifiers, DID documents, handle resolution, blobs, AT URIs, and account identity. |
DNSResolverClient |
Dependency-controlled DNS TXT lookup support used by handle resolution. |
XRPCClient |
Typed XRPC request modeling, routing, query encoding, and the dependency client that runs requests. |
ATProtoOAuth |
AT Protocol OAuth primitives, app metadata, PAR/PKCE/DPoP, sessions, refresh, permission checks, and authorization helpers. |
ATProtoServer |
A small server client surface; currently includes com.atproto.server.getSession. |
Lexicon |
Helpers for open tagged unions and open scalar values when decoding lexicon-shaped data. |
LexiconSQLiteData |
SQLiteData support for open lexicon values. |
TCATProtoAuthentication |
TCA 2 and SwiftUI helpers for authenticated app shells, enabled by the TCA26 trait. |
ComposableAtmosphereAuthentication |
TCA 1.0 OAuth integration support, enabled by the TCA1OAuth trait. |
OAuth#
Configure your app's OAuth identity and manifest up front:
import ATProtoOAuth
import Dependencies
import Foundation
prepareDependencies {
$0.oauthAppConfiguration = OAuth.AppConfiguration(
appID: OAuth.App.ID(
URL(string: "https://example.com/oauth-client-metadata.json")!
),
redirectURI: OAuth.App.RedirectURI(
URL(string: "example:/oauth/callback")!
)
)
$0.oauthAppManifest = OAuth.AppManifest(
appID: OAuth.App.ID(
URL(string: "https://example.com/oauth-client-metadata.json")!
),
appName: "Example",
redirectURIs: [
OAuth.App.RedirectURI(URL(string: "example:/oauth/callback")!)
],
basePermissions: [.atproto],
featurePermissions: [
"posting": [
.repo("app.bsky.feed.post", actions: [.create])
]
]
)
}
OAuth.AppManifest can encode the client metadata you serve from your app ID URL:
@Dependency(\.oauthAppManifest) var oauthAppManifest
let metadata = try oauthAppManifest.metadataData()
Authorization requirements are checked against the current session and the manifest:
try await withDefaultSessionAuthorization(
scopes: [
.repo("app.bsky.feed.post", actions: [.create])
]
) {
// Runs with the current DPoP-bound OAuth session installed on outgoing requests.
}
If the session is missing, belongs to the wrong account, lacks a permission that the manifest can
request, or asks for a permission the manifest cannot request, Composable Atmosphere throws an
OAuth.AuthorizationRequired value that the app can route into the right sign-in or upgrade flow.
Lexicons#
AT Protocol lexicons often contain open unions: the app knows about some $type cases, but must
still preserve data it does not understand. The Lexicon product provides Open<Known> and
OpenUnion tools for this shape.
Known cases decode into Swift enum cases. Unknown cases stay available as structured partial JSON values so clients can round-trip or inspect them without dropping data.
Testing#
Composable Atmosphere follows the same dependency-control style as the Point-Free libraries it builds on. Instead of reaching directly into globals or live networking, features can override clients for one test:
try await withDependencies {
$0.pdsHost = "public.api.bsky.app"
$0.xrpcAuthorizationGate = XRPC.AuthorizationGate { policy, routing, operation in
// Assert on the policy or install headers before running the transport.
try await routing.withUnauthenticatedRoute {
try await operation()
}
}
} operation: {
@Dependency(XRPC.Client.self) var xrpcClient
_ = try await xrpcClient.run(GetPosts(parameters: .init(uris: [])))
}
The package uses Swift Testing and DependenciesTestSupport.
Installation#
swift-composable-atmosphere is a Swift package targeting iOS 17 and macOS 14 with Swift 6.3.
Add the package to Package.swift:
dependencies: [
.package(
name: "swift-composable-atmosphere",
url: "https://source.woody.fm/swift-composable-atmosphere",
branch: "main"
)
]
If you need TCA integration, opt into the relevant package trait:
dependencies: [
.package(
name: "swift-composable-atmosphere",
url: "https://source.woody.fm/swift-composable-atmosphere",
branch: "main",
traits: ["TCA26"] // or ["TCA1OAuth"]
)
]
Then add the products you need to your target:
.target(
name: "MyApp",
dependencies: [
.product(name: "ATProto", package: "swift-composable-atmosphere"),
.product(name: "XRPCClient", package: "swift-composable-atmosphere"),
.product(name: "ATProtoOAuth", package: "swift-composable-atmosphere"),
.product(name: "TCATProtoAuthentication", package: "swift-composable-atmosphere"),
]
)
For local development in the Composable Atmosphere workspace, a path dependency is often more convenient:
.package(name: "swift-composable-atmosphere", path: "../swift-composable-atmosphere")
License#
This repository does not currently include a license file.