···88import Foundation
99import CoreATProtocol
10101111+struct SendableAny: @unchecked Sendable {
1212+ let value: Any
1313+}
1414+1115enum BskyAPI {
1216 // Actor endpoints
1317 case getPreferences
1418 case getProfile(did: String)
1519 case getProfiles(dids: [String])
2020+ case getSuggestions(limit: Int, cursor: String?)
1621 case searchActors(query: String, limit: Int)
1722 case searchActorsTypeahead(query: String, limit: Int)
18231924 // Feed endpoints
2025 case getFeed(feed: String, limit: Int, cursor: String?)
2626+ case getActorFeeds(actor: String, limit: Int, cursor: String?)
2727+ case getFeedGenerator(feed: String)
2128 case getFeedGenerators(feeds: [String])
2929+ case getListFeed(list: String, limit: Int, cursor: String?)
3030+ case getQuotes(uri: String, cid: String?, limit: Int, cursor: String?)
3131+ case getSuggestedFeeds(limit: Int, cursor: String?)
2232 case getTimeline(limit: Int, cursor: String?)
2333 case getAuthorFeed(did: String, limit: Int, cursor: String?, filter: String?)
2434 case getPostThread(uri: String, depth: Int)
2535 case getPosts(uris: [String])
3636+ case searchPosts(
3737+ query: String,
3838+ sort: String?,
3939+ since: String?,
4040+ until: String?,
4141+ mentions: String?,
4242+ author: String?,
4343+ lang: String?,
4444+ domain: String?,
4545+ url: String?,
4646+ tags: [String]?,
4747+ limit: Int,
4848+ cursor: String?
4949+ )
2650 case getActorLikes(did: String, limit: Int, cursor: String?)
2751 case getLikes(uri: String, limit: Int, cursor: String?)
2852 case getRepostedBy(uri: String, limit: Int, cursor: String?)
···3256 case getFollowers(did: String, limit: Int, cursor: String?)
3357 case getBlocks(limit: Int, cursor: String?)
3458 case getMutes(limit: Int, cursor: String?)
5959+ case getRelationships(actor: String, others: [String])
6060+ case muteActor(actor: String)
6161+ case unmuteActor(actor: String)
6262+ case muteThread(root: String)
6363+ case unmuteThread(root: String)
6464+ case muteActorList(list: String)
6565+ case unmuteActorList(list: String)
35663667 // Notification endpoints
3768 case listNotifications(limit: Int, cursor: String?)
3869 case getUnreadCount
3970 case updateSeen(seenAt: Date)
7171+7272+ // Generic endpoints for newer/less-common lexicons.
7373+ case xrpcQuery(id: String, parameters: [String: SendableAny])
7474+ case xrpcProcedure(id: String, body: [String: SendableAny]?)
7575+ case xrpcDataProcedure(id: String, data: Data, contentType: String, accept: String?)
4076}
41774278extension BskyAPI: EndpointType {
4379 public var baseURL: URL {
4480 get async {
4545- guard let host = await APEnvironment.current.host else { fatalError("Host not set.") }
4646- guard let url = URL(string: host) else { fatalError("BskyAPI baseURL not configured.") }
8181+ guard let host = await APEnvironment.current.host,
8282+ let url = URL(string: host) else {
8383+ return URL(string: "https://invalid.invalid")!
8484+ }
4785 return url
4886 }
4987 }
···5492 case .getPreferences: "/xrpc/app.bsky.actor.getPreferences"
5593 case .getProfile: "/xrpc/app.bsky.actor.getProfile"
5694 case .getProfiles: "/xrpc/app.bsky.actor.getProfiles"
9595+ case .getSuggestions: "/xrpc/app.bsky.actor.getSuggestions"
5796 case .searchActors: "/xrpc/app.bsky.actor.searchActors"
5897 case .searchActorsTypeahead: "/xrpc/app.bsky.actor.searchActorsTypeahead"
5998 // Feed
6099 case .getFeed: "/xrpc/app.bsky.feed.getFeed"
100100+ case .getActorFeeds: "/xrpc/app.bsky.feed.getActorFeeds"
101101+ case .getFeedGenerator: "/xrpc/app.bsky.feed.getFeedGenerator"
61102 case .getFeedGenerators: "/xrpc/app.bsky.feed.getFeedGenerators"
103103+ case .getListFeed: "/xrpc/app.bsky.feed.getListFeed"
104104+ case .getQuotes: "/xrpc/app.bsky.feed.getQuotes"
105105+ case .getSuggestedFeeds: "/xrpc/app.bsky.feed.getSuggestedFeeds"
62106 case .getTimeline: "/xrpc/app.bsky.feed.getTimeline"
63107 case .getAuthorFeed: "/xrpc/app.bsky.feed.getAuthorFeed"
64108 case .getPostThread: "/xrpc/app.bsky.feed.getPostThread"
65109 case .getPosts: "/xrpc/app.bsky.feed.getPosts"
110110+ case .searchPosts: "/xrpc/app.bsky.feed.searchPosts"
66111 case .getActorLikes: "/xrpc/app.bsky.feed.getActorLikes"
67112 case .getLikes: "/xrpc/app.bsky.feed.getLikes"
68113 case .getRepostedBy: "/xrpc/app.bsky.feed.getRepostedBy"
···71116 case .getFollowers: "/xrpc/app.bsky.graph.getFollowers"
72117 case .getBlocks: "/xrpc/app.bsky.graph.getBlocks"
73118 case .getMutes: "/xrpc/app.bsky.graph.getMutes"
119119+ case .getRelationships: "/xrpc/app.bsky.graph.getRelationships"
120120+ case .muteActor: "/xrpc/app.bsky.graph.muteActor"
121121+ case .unmuteActor: "/xrpc/app.bsky.graph.unmuteActor"
122122+ case .muteThread: "/xrpc/app.bsky.graph.muteThread"
123123+ case .unmuteThread: "/xrpc/app.bsky.graph.unmuteThread"
124124+ case .muteActorList: "/xrpc/app.bsky.graph.muteActorList"
125125+ case .unmuteActorList: "/xrpc/app.bsky.graph.unmuteActorList"
74126 // Notifications
75127 case .listNotifications: "/xrpc/app.bsky.notification.listNotifications"
76128 case .getUnreadCount: "/xrpc/app.bsky.notification.getUnreadCount"
77129 case .updateSeen: "/xrpc/app.bsky.notification.updateSeen"
130130+ case .xrpcQuery(let id, _), .xrpcProcedure(let id, _), .xrpcDataProcedure(let id, _, _, _):
131131+ "/xrpc/\(id)"
78132 }
79133 }
8013481135 var httpMethod: HTTPMethod {
82136 switch self {
8383- case .getPreferences, .getProfile, .getProfiles, .searchActors, .searchActorsTypeahead,
8484- .getFeed, .getFeedGenerators, .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .getActorLikes, .getLikes, .getRepostedBy,
8585- .getFollows, .getFollowers, .getBlocks, .getMutes,
137137+ case .getPreferences, .getProfile, .getProfiles, .getSuggestions, .searchActors, .searchActorsTypeahead,
138138+ .getFeed, .getActorFeeds, .getFeedGenerator, .getFeedGenerators, .getListFeed, .getQuotes, .getSuggestedFeeds,
139139+ .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .searchPosts, .getActorLikes, .getLikes, .getRepostedBy,
140140+ .getFollows, .getFollowers, .getBlocks, .getMutes, .getRelationships,
86141 .listNotifications, .getUnreadCount:
87142 return .get
8888- case .updateSeen:
143143+ case .updateSeen, .muteActor, .unmuteActor, .muteThread, .unmuteThread, .muteActorList, .unmuteActorList,
144144+ .xrpcProcedure, .xrpcDataProcedure:
89145 return .post
146146+ case .xrpcQuery:
147147+ return .get
90148 }
91149 }
92150···102160 case .getProfiles(let dids):
103161 return .requestParameters(encoding: .urlEncoding(parameters: ["actors": dids]))
104162163163+ case .getSuggestions(let limit, let cursor):
164164+ var params: Parameters = ["limit": limit]
165165+ if let cursor { params["cursor"] = cursor }
166166+ return .requestParameters(encoding: .urlEncoding(parameters: params))
167167+105168 case .searchActors(let query, let limit):
106169 return .requestParameters(encoding: .urlEncoding(parameters: [
107170 "q": query,
···120183 if let cursor { params["cursor"] = cursor }
121184 return .requestParameters(encoding: .urlEncoding(parameters: params))
122185186186+ case .getActorFeeds(let actor, let limit, let cursor):
187187+ var params: Parameters = ["actor": actor, "limit": limit]
188188+ if let cursor { params["cursor"] = cursor }
189189+ return .requestParameters(encoding: .urlEncoding(parameters: params))
190190+191191+ case .getFeedGenerator(let feed):
192192+ return .requestParameters(encoding: .urlEncoding(parameters: ["feed": feed]))
193193+123194 case .getFeedGenerators(let feeds):
124195 return .requestParameters(encoding: .urlEncoding(parameters: ["feeds": feeds]))
125196197197+ case .getListFeed(let list, let limit, let cursor):
198198+ var params: Parameters = ["list": list, "limit": limit]
199199+ if let cursor { params["cursor"] = cursor }
200200+ return .requestParameters(encoding: .urlEncoding(parameters: params))
201201+202202+ case .getQuotes(let uri, let cid, let limit, let cursor):
203203+ var params: Parameters = ["uri": uri, "limit": limit]
204204+ if let cid { params["cid"] = cid }
205205+ if let cursor { params["cursor"] = cursor }
206206+ return .requestParameters(encoding: .urlEncoding(parameters: params))
207207+208208+ case .getSuggestedFeeds(let limit, let cursor):
209209+ var params: Parameters = ["limit": limit]
210210+ if let cursor { params["cursor"] = cursor }
211211+ return .requestParameters(encoding: .urlEncoding(parameters: params))
212212+126213 case .getTimeline(let limit, let cursor):
127214 var params: Parameters = ["limit": limit]
128215 if let cursor { params["cursor"] = cursor }
···143230 case .getPosts(let uris):
144231 return .requestParameters(encoding: .urlEncoding(parameters: ["uris": uris]))
145232233233+ case .searchPosts(
234234+ let query,
235235+ let sort,
236236+ let since,
237237+ let until,
238238+ let mentions,
239239+ let author,
240240+ let lang,
241241+ let domain,
242242+ let url,
243243+ let tags,
244244+ let limit,
245245+ let cursor
246246+ ):
247247+ var params: Parameters = ["q": query, "limit": limit]
248248+ if let sort { params["sort"] = sort }
249249+ if let since { params["since"] = since }
250250+ if let until { params["until"] = until }
251251+ if let mentions { params["mentions"] = mentions }
252252+ if let author { params["author"] = author }
253253+ if let lang { params["lang"] = lang }
254254+ if let domain { params["domain"] = domain }
255255+ if let url { params["url"] = url }
256256+ if let tags, !tags.isEmpty { params["tag"] = tags }
257257+ if let cursor { params["cursor"] = cursor }
258258+ return .requestParameters(encoding: .urlEncoding(parameters: params))
259259+146260 case .getActorLikes(let did, let limit, let cursor):
147261 var params: Parameters = ["actor": did, "limit": limit]
148262 if let cursor { params["cursor"] = cursor }
···179293 if let cursor { params["cursor"] = cursor }
180294 return .requestParameters(encoding: .urlEncoding(parameters: params))
181295296296+ case .getRelationships(let actor, let others):
297297+ return .requestParameters(encoding: .urlEncoding(parameters: [
298298+ "actor": actor,
299299+ "others": others
300300+ ]))
301301+302302+ case .muteActor(let actor), .unmuteActor(let actor):
303303+ return .requestParameters(encoding: .jsonEncoding(parameters: ["actor": actor]))
304304+305305+ case .muteThread(let root), .unmuteThread(let root):
306306+ return .requestParameters(encoding: .jsonEncoding(parameters: ["root": root]))
307307+308308+ case .muteActorList(let list), .unmuteActorList(let list):
309309+ return .requestParameters(encoding: .jsonEncoding(parameters: ["list": list]))
310310+182311 // Notification endpoints
183312 case .listNotifications(let limit, let cursor):
184313 var params: Parameters = ["limit": limit]
···191320 return .requestParameters(encoding: .jsonEncoding(parameters: [
192321 "seenAt": formatter.string(from: seenAt)
193322 ]))
323323+324324+ case .xrpcQuery(_, let parameters):
325325+ let unboxed = parameters.mapValues(\.value)
326326+ if parameters.isEmpty {
327327+ return .request
328328+ }
329329+ return .requestParameters(encoding: .urlEncoding(parameters: unboxed))
330330+331331+ case .xrpcProcedure(_, let body):
332332+ guard let body else { return .request }
333333+ return .requestParameters(encoding: .jsonEncoding(parameters: body.mapValues(\.value)))
334334+335335+ case .xrpcDataProcedure(_, let data, _, _):
336336+ return .requestParameters(encoding: .jsonDataEncoding(data: data))
194337 }
195338 }
196339197340 var headers: HTTPHeaders? {
198198- nil
341341+ switch self {
342342+ case .xrpcDataProcedure(_, _, let contentType, let accept):
343343+ var headers: HTTPHeaders = ["Content-Type": contentType]
344344+ if let accept { headers["Accept"] = accept }
345345+ return headers
346346+ default:
347347+ return nil
348348+ }
199349 }
200350}
+596-22
Sources/bskyKit/BskyService.swift
···7272 /// `setup(hostURL:accessJWT:refreshJWT:)`.
7373 public init() {}
74747575+ private func execute<T: Decodable>(_ endpoint: BskyAPI) async throws -> T {
7676+ try ensureHostConfigured()
7777+ return try await router.execute(endpoint)
7878+ }
7979+8080+ private func executeQuery<T: Decodable>(
8181+ _ id: String,
8282+ parameters: Parameters = [:]
8383+ ) async throws -> T {
8484+ try await execute(.xrpcQuery(id: id, parameters: box(parameters)))
8585+ }
8686+8787+ private func executeProcedure<T: Decodable>(
8888+ _ id: String,
8989+ body: Parameters? = nil
9090+ ) async throws -> T {
9191+ try await execute(.xrpcProcedure(id: id, body: body.map(box)))
9292+ }
9393+9494+ private func executeDataProcedure<T: Decodable>(
9595+ _ id: String,
9696+ data: Data,
9797+ contentType: String,
9898+ accept: String? = "application/json"
9999+ ) async throws -> T {
100100+ try await execute(.xrpcDataProcedure(
101101+ id: id,
102102+ data: data,
103103+ contentType: contentType,
104104+ accept: accept
105105+ ))
106106+ }
107107+108108+ private func box(_ parameters: Parameters) -> [String: SendableAny] {
109109+ parameters.mapValues { SendableAny(value: $0) }
110110+ }
111111+75112 // MARK: - Actor
7611377114 /// Fetches the authenticated user's preferences.
78115 /// - Returns: The user's saved preferences including pinned feeds.
79116 /// - Throws: An error if the request fails or the user is not authenticated.
80117 public func getPreferences() async throws -> Preferences {
8181- try await router.execute(.getPreferences)
118118+ try await execute(.getPreferences)
82119 }
8312084121 /// Fetches a user profile by handle or DID.
···86123 /// - Returns: The user's profile.
87124 /// - Throws: An error if the profile is not found or the request fails.
88125 public func getProfile(for did: String) async throws -> Profile {
8989- try await router.execute(.getProfile(did: did))
126126+ try await execute(.getProfile(did: did))
90127 }
9112892129 /// Fetches multiple user profiles in a single request.
···94131 /// - Returns: The profiles for the requested users.
95132 /// - Throws: An error if the request fails.
96133 public func getProfiles(for dids: [String]) async throws -> Profiles {
9797- try await router.execute(.getProfiles(dids: dids))
134134+ try await execute(.getProfiles(dids: dids))
135135+ }
136136+137137+ /// Fetches actor suggestions for the authenticated user.
138138+ public func getSuggestions(limit: Int = 25, cursor: String? = nil) async throws -> ActorSuggestionsResponse {
139139+ try await execute(.getSuggestions(limit: limit, cursor: cursor))
98140 }
99141100142 /// Searches for users matching a query.
···104146 /// - Returns: Matching user profiles with optional cursor for pagination.
105147 /// - Throws: An error if the request fails.
106148 public func searchActors(query: String, limit: Int = 25) async throws -> SearchActorsResult {
107107- try await router.execute(.searchActors(query: query, limit: limit))
149149+ try await execute(.searchActors(query: query, limit: limit))
108150 }
109151110152 /// Fast search for autocomplete functionality.
···114156 /// - Returns: Matching profiles optimized for autocomplete.
115157 /// - Throws: An error if the request fails.
116158 public func searchActorsTypeahead(query: String, limit: Int = 10) async throws -> SearchActorsTypeaheadResult {
117117- try await router.execute(.searchActorsTypeahead(query: query, limit: limit))
159159+ try await execute(.searchActorsTypeahead(query: query, limit: limit))
118160 }
119161120162 // MARK: - Feed
···127169 /// - Returns: Feed posts with cursor for pagination.
128170 /// - Throws: An error if the feed is not found or the request fails.
129171 public func getFeed(feed: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed {
130130- try await router.execute(.getFeed(feed: feed, limit: limit, cursor: cursor))
172172+ try await execute(.getFeed(feed: feed, limit: limit, cursor: cursor))
173173+ }
174174+175175+ /// Fetches feed generators by actor.
176176+ public func getActorFeeds(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> FeedPageResponse {
177177+ try await execute(.getActorFeeds(actor: actor, limit: limit, cursor: cursor))
178178+ }
179179+180180+ /// Fetches a single feed generator by AT-URI.
181181+ public func getFeedGenerator(feed: String) async throws -> FeedGeneratorResponse {
182182+ try await execute(.getFeedGenerator(feed: feed))
131183 }
132184133185 /// Fetches information about custom feed generators.
···135187 /// - Returns: Details about the requested feed generators.
136188 /// - Throws: An error if the request fails.
137189 public func getFeedGenerators(for feeds: [String]) async throws -> Feeds {
138138- try await router.execute(.getFeedGenerators(feeds: feeds))
190190+ try await execute(.getFeedGenerators(feeds: feeds))
191191+ }
192192+193193+ /// Fetches posts from a list feed.
194194+ public func getListFeed(list: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed {
195195+ try await execute(.getListFeed(list: list, limit: limit, cursor: cursor))
196196+ }
197197+198198+ /// Fetches quotes for a post.
199199+ public func getQuotes(uri: String, cid: String? = nil, limit: Int = 50, cursor: String? = nil) async throws -> QuotesResponse {
200200+ try await execute(.getQuotes(uri: uri, cid: cid, limit: limit, cursor: cursor))
201201+ }
202202+203203+ /// Fetches suggested feed generators.
204204+ public func getSuggestedFeeds(limit: Int = 50, cursor: String? = nil) async throws -> FeedPageResponse {
205205+ try await execute(.getSuggestedFeeds(limit: limit, cursor: cursor))
139206 }
140207141208 /// Fetches the authenticated user's home timeline.
···145212 /// - Returns: Timeline posts with cursor for pagination.
146213 /// - Throws: An error if the user is not authenticated or the request fails.
147214 public func getTimeline(limit: Int = 50, cursor: String? = nil) async throws -> Timeline {
148148- try await router.execute(.getTimeline(limit: limit, cursor: cursor))
215215+ try await execute(.getTimeline(limit: limit, cursor: cursor))
149216 }
150217151218 /// Fetches posts from a specific user's feed.
···157224 /// - Returns: The user's posts with cursor for pagination.
158225 /// - Throws: An error if the request fails.
159226 public func getAuthorFeed(for did: String, limit: Int = 50, cursor: String? = nil, filter: String? = nil) async throws -> AuthorFeed {
160160- try await router.execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor, filter: filter))
227227+ try await execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor, filter: filter))
161228 }
162229163230 /// Fetches a post and its reply thread.
···167234 /// - Returns: The post thread with nested replies.
168235 /// - Throws: An error if the post is not found or the request fails.
169236 public func getPostThread(uri: String, depth: Int = 6) async throws -> PostThreadResponse {
170170- try await router.execute(.getPostThread(uri: uri, depth: depth))
237237+ try await execute(.getPostThread(uri: uri, depth: depth))
171238 }
172239173240 /// Fetches multiple posts by URI in a single request.
···175242 /// - Returns: The requested posts.
176243 /// - Throws: An error if the request fails.
177244 public func getPosts(uris: [String]) async throws -> Posts {
178178- try await router.execute(.getPosts(uris: uris))
245245+ try await execute(.getPosts(uris: uris))
246246+ }
247247+248248+ /// Searches posts.
249249+ public func searchPosts(
250250+ query: String,
251251+ sort: String? = nil,
252252+ since: String? = nil,
253253+ until: String? = nil,
254254+ mentions: String? = nil,
255255+ author: String? = nil,
256256+ lang: String? = nil,
257257+ domain: String? = nil,
258258+ url: String? = nil,
259259+ tags: [String]? = nil,
260260+ limit: Int = 25,
261261+ cursor: String? = nil
262262+ ) async throws -> SearchPostsResponse {
263263+ try await execute(.searchPosts(
264264+ query: query,
265265+ sort: sort,
266266+ since: since,
267267+ until: until,
268268+ mentions: mentions,
269269+ author: author,
270270+ lang: lang,
271271+ domain: domain,
272272+ url: url,
273273+ tags: tags,
274274+ limit: limit,
275275+ cursor: cursor
276276+ ))
179277 }
180278181279 /// Fetches posts liked by a specific user.
···186284 /// - Returns: The user's liked posts with cursor for pagination.
187285 /// - Throws: An error if the request fails.
188286 public func getActorLikes(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed {
189189- try await router.execute(.getActorLikes(did: did, limit: limit, cursor: cursor))
287287+ try await execute(.getActorLikes(did: did, limit: limit, cursor: cursor))
190288 }
191289192290 /// Fetches users who liked a specific post.
···197295 /// - Returns: Users who liked the post with cursor for pagination.
198296 /// - Throws: An error if the request fails.
199297 public func getLikes(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> Likes {
200200- try await router.execute(.getLikes(uri: uri, limit: limit, cursor: cursor))
298298+ try await execute(.getLikes(uri: uri, limit: limit, cursor: cursor))
201299 }
202300203301 /// Fetches users who reposted a specific post.
···208306 /// - Returns: Users who reposted with cursor for pagination.
209307 /// - Throws: An error if the request fails.
210308 public func getRepostedBy(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> RepostedBy {
211211- try await router.execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor))
309309+ try await execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor))
212310 }
213311214312 // MARK: - Graph
···221319 /// - Returns: Users being followed with cursor for pagination.
222320 /// - Throws: An error if the request fails.
223321 public func getFollows(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Follows {
224224- try await router.execute(.getFollows(did: did, limit: limit, cursor: cursor))
322322+ try await execute(.getFollows(did: did, limit: limit, cursor: cursor))
225323 }
226324227325 /// Fetches the list of users following a specific user.
···232330 /// - Returns: Followers with cursor for pagination.
233331 /// - Throws: An error if the request fails.
234332 public func getFollowers(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers {
235235- try await router.execute(.getFollowers(did: did, limit: limit, cursor: cursor))
333333+ try await execute(.getFollowers(did: did, limit: limit, cursor: cursor))
236334 }
237335238336 /// Fetches the authenticated user's blocked accounts.
···242340 /// - Returns: Blocked profiles with cursor for pagination.
243341 /// - Throws: An error if not authenticated or the request fails.
244342 public func getBlocks(limit: Int = 50, cursor: String? = nil) async throws -> Blocks {
245245- try await router.execute(.getBlocks(limit: limit, cursor: cursor))
343343+ try await execute(.getBlocks(limit: limit, cursor: cursor))
246344 }
247345248346 /// Fetches the authenticated user's muted accounts.
···252350 /// - Returns: Muted profiles with cursor for pagination.
253351 /// - Throws: An error if not authenticated or the request fails.
254352 public func getMutes(limit: Int = 50, cursor: String? = nil) async throws -> Mutes {
255255- try await router.execute(.getMutes(limit: limit, cursor: cursor))
353353+ try await execute(.getMutes(limit: limit, cursor: cursor))
354354+ }
355355+356356+ /// Fetches relationship state between an actor and other actors.
357357+ public func getRelationships(for actor: String, others: [String]) async throws -> RelationshipsResponse {
358358+ try await execute(.getRelationships(actor: actor, others: others))
359359+ }
360360+361361+ /// Mutes an actor.
362362+ public func muteActor(_ actor: String) async throws {
363363+ let _: EmptyResponse = try await execute(.muteActor(actor: actor))
364364+ }
365365+366366+ /// Unmutes an actor.
367367+ public func unmuteActor(_ actor: String) async throws {
368368+ let _: EmptyResponse = try await execute(.unmuteActor(actor: actor))
369369+ }
370370+371371+ /// Mutes a thread by root URI.
372372+ public func muteThread(root: String) async throws {
373373+ let _: EmptyResponse = try await execute(.muteThread(root: root))
374374+ }
375375+376376+ /// Unmutes a thread by root URI.
377377+ public func unmuteThread(root: String) async throws {
378378+ let _: EmptyResponse = try await execute(.unmuteThread(root: root))
379379+ }
380380+381381+ /// Mutes an actor list by URI.
382382+ public func muteActorList(_ list: String) async throws {
383383+ let _: EmptyResponse = try await execute(.muteActorList(list: list))
384384+ }
385385+386386+ /// Unmutes an actor list by URI.
387387+ public func unmuteActorList(_ list: String) async throws {
388388+ let _: EmptyResponse = try await execute(.unmuteActorList(list: list))
256389 }
257390258391 // MARK: - Notifications
···264397 /// - Returns: Notifications with cursor for pagination.
265398 /// - Throws: An error if not authenticated or the request fails.
266399 public func listNotifications(limit: Int = 50, cursor: String? = nil) async throws -> NotificationsResponse {
267267- try await router.execute(.listNotifications(limit: limit, cursor: cursor))
400400+ try await execute(.listNotifications(limit: limit, cursor: cursor))
268401 }
269402270403 /// Fetches the count of unread notifications.
271404 /// - Returns: The number of unread notifications.
272405 /// - Throws: An error if not authenticated or the request fails.
273406 public func getUnreadCount() async throws -> UnreadCount {
274274- try await router.execute(.getUnreadCount)
407407+ try await execute(.getUnreadCount)
275408 }
276409277410 /// Marks notifications as seen up to the specified time.
278411 /// - Parameter date: The timestamp to mark as seen (default: now).
279412 /// - Throws: An error if not authenticated or the request fails.
280413 public func updateSeen(at date: Date = Date()) async throws {
281281- let _: EmptyResponse = try await router.execute(.updateSeen(seenAt: date))
414414+ let _: EmptyResponse = try await execute(.updateSeen(seenAt: date))
415415+ }
416416+417417+ // MARK: - Priority 2: Account, Bookmark, and Graph Collections
418418+419419+ /// Updates actor preferences payload.
420420+ public func putPreferences(_ preferences: [String: Any]) async throws {
421421+ let _: EmptyResponse = try await executeProcedure(
422422+ "app.bsky.actor.putPreferences",
423423+ body: ["preferences": preferences]
424424+ )
425425+ }
426426+427427+ /// Creates a bookmark for a post.
428428+ public func createBookmark(uri: String, cid: String) async throws {
429429+ let _: EmptyResponse = try await executeProcedure(
430430+ "app.bsky.bookmark.createBookmark",
431431+ body: ["uri": uri, "cid": cid]
432432+ )
433433+ }
434434+435435+ /// Deletes a bookmark by post URI.
436436+ public func deleteBookmark(uri: String) async throws {
437437+ let _: EmptyResponse = try await executeProcedure(
438438+ "app.bsky.bookmark.deleteBookmark",
439439+ body: ["uri": uri]
440440+ )
441441+ }
442442+443443+ /// Lists bookmarks for the authenticated actor.
444444+ public func getBookmarks(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
445445+ var params: Parameters = ["limit": limit]
446446+ if let cursor { params["cursor"] = cursor }
447447+ return try await executeQuery("app.bsky.bookmark.getBookmarks", parameters: params)
282448 }
283283-}
284449450450+ /// Describes the current feed generator account and feeds.
451451+ public func describeFeedGenerator() async throws -> JSONValue {
452452+ try await executeQuery("app.bsky.feed.describeFeedGenerator")
453453+ }
454454+455455+ /// Fetches raw feed skeleton response for a feed generator.
456456+ public func getFeedSkeleton(feed: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
457457+ var params: Parameters = ["feed": feed, "limit": limit]
458458+ if let cursor { params["cursor"] = cursor }
459459+ return try await executeQuery("app.bsky.feed.getFeedSkeleton", parameters: params)
460460+ }
461461+462462+ /// Sends interaction signals to ranking services.
463463+ public func sendInteractions(_ interactions: [[String: Any]]) async throws {
464464+ let _: EmptyResponse = try await executeProcedure(
465465+ "app.bsky.feed.sendInteractions",
466466+ body: ["interactions": interactions]
467467+ )
468468+ }
469469+470470+ /// Fetches starter packs authored by an actor.
471471+ public func getActorStarterPacks(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
472472+ var params: Parameters = ["actor": actor, "limit": limit]
473473+ if let cursor { params["cursor"] = cursor }
474474+ return try await executeQuery("app.bsky.graph.getActorStarterPacks", parameters: params)
475475+ }
476476+477477+ /// Fetches known followers for an actor.
478478+ public func getKnownFollowers(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers {
479479+ var params: Parameters = ["actor": actor, "limit": limit]
480480+ if let cursor { params["cursor"] = cursor }
481481+ return try await executeQuery("app.bsky.graph.getKnownFollowers", parameters: params)
482482+ }
483483+484484+ /// Fetches a list and its membership.
485485+ public func getList(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
486486+ var params: Parameters = ["list": uri, "limit": limit]
487487+ if let cursor { params["cursor"] = cursor }
488488+ return try await executeQuery("app.bsky.graph.getList", parameters: params)
489489+ }
490490+491491+ /// Fetches lists blocked by the authenticated actor.
492492+ public func getListBlocks(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
493493+ var params: Parameters = ["limit": limit]
494494+ if let cursor { params["cursor"] = cursor }
495495+ return try await executeQuery("app.bsky.graph.getListBlocks", parameters: params)
496496+ }
497497+498498+ /// Fetches lists muted by the authenticated actor.
499499+ public func getListMutes(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
500500+ var params: Parameters = ["limit": limit]
501501+ if let cursor { params["cursor"] = cursor }
502502+ return try await executeQuery("app.bsky.graph.getListMutes", parameters: params)
503503+ }
504504+505505+ /// Fetches lists created by an actor.
506506+ public func getLists(
507507+ for actor: String,
508508+ limit: Int = 50,
509509+ cursor: String? = nil,
510510+ purposes: [String]? = nil
511511+ ) async throws -> JSONValue {
512512+ var params: Parameters = ["actor": actor, "limit": limit]
513513+ if let cursor { params["cursor"] = cursor }
514514+ if let purposes, !purposes.isEmpty { params["purposes"] = purposes }
515515+ return try await executeQuery("app.bsky.graph.getLists", parameters: params)
516516+ }
517517+518518+ /// Fetches lists with membership state for an actor.
519519+ public func getListsWithMembership(
520520+ for actor: String,
521521+ limit: Int = 50,
522522+ cursor: String? = nil,
523523+ purposes: [String]? = nil
524524+ ) async throws -> JSONValue {
525525+ var params: Parameters = ["actor": actor, "limit": limit]
526526+ if let cursor { params["cursor"] = cursor }
527527+ if let purposes, !purposes.isEmpty { params["purposes"] = purposes }
528528+ return try await executeQuery("app.bsky.graph.getListsWithMembership", parameters: params)
529529+ }
530530+531531+ /// Fetches a single starter pack.
532532+ public func getStarterPack(uri: String) async throws -> JSONValue {
533533+ try await executeQuery("app.bsky.graph.getStarterPack", parameters: ["starterPack": uri])
534534+ }
535535+536536+ /// Fetches multiple starter packs by URI.
537537+ public func getStarterPacks(uris: [String]) async throws -> JSONValue {
538538+ try await executeQuery("app.bsky.graph.getStarterPacks", parameters: ["uris": uris])
539539+ }
540540+541541+ /// Fetches starter packs with membership for an actor.
542542+ public func getStarterPacksWithMembership(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
543543+ var params: Parameters = ["actor": actor, "limit": limit]
544544+ if let cursor { params["cursor"] = cursor }
545545+ return try await executeQuery("app.bsky.graph.getStarterPacksWithMembership", parameters: params)
546546+ }
547547+548548+ /// Fetches suggested follows for an actor.
549549+ public func getSuggestedFollowsByActor(for actor: String) async throws -> JSONValue {
550550+ try await executeQuery("app.bsky.graph.getSuggestedFollowsByActor", parameters: ["actor": actor])
551551+ }
552552+553553+ /// Searches starter packs.
554554+ public func searchStarterPacks(query: String, limit: Int = 25, cursor: String? = nil) async throws -> JSONValue {
555555+ var params: Parameters = ["q": query, "limit": limit]
556556+ if let cursor { params["cursor"] = cursor }
557557+ return try await executeQuery("app.bsky.graph.searchStarterPacks", parameters: params)
558558+ }
559559+560560+ /// Fetches labeler services by DID.
561561+ public func getLabelerServices(dids: [String], detailed: Bool? = nil) async throws -> JSONValue {
562562+ var params: Parameters = ["dids": dids]
563563+ if let detailed { params["detailed"] = detailed }
564564+ return try await executeQuery("app.bsky.labeler.getServices", parameters: params)
565565+ }
566566+567567+ // MARK: - Priority 3: Notification Preferences and Push
568568+569569+ /// Fetches notification preferences.
570570+ public func getNotificationPreferences() async throws -> JSONValue {
571571+ try await executeQuery("app.bsky.notification.getPreferences")
572572+ }
573573+574574+ /// Lists activity subscriptions.
575575+ public func listActivitySubscriptions(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue {
576576+ var params: Parameters = ["limit": limit]
577577+ if let cursor { params["cursor"] = cursor }
578578+ return try await executeQuery("app.bsky.notification.listActivitySubscriptions", parameters: params)
579579+ }
580580+581581+ /// Upserts an activity subscription for a subject.
582582+ public func putActivitySubscription(subject: String, activitySubscription: [String: Any]) async throws -> JSONValue {
583583+ try await executeProcedure(
584584+ "app.bsky.notification.putActivitySubscription",
585585+ body: [
586586+ "subject": subject,
587587+ "activitySubscription": activitySubscription
588588+ ]
589589+ )
590590+ }
591591+592592+ /// Updates legacy notification priority setting.
593593+ public func putNotificationPreferences(priority: Bool) async throws {
594594+ let _: EmptyResponse = try await executeProcedure(
595595+ "app.bsky.notification.putPreferences",
596596+ body: ["priority": priority]
597597+ )
598598+ }
599599+600600+ /// Updates v2 notification preference payload.
601601+ public func putNotificationPreferencesV2(_ preferences: [String: Any]) async throws -> JSONValue {
602602+ try await executeProcedure("app.bsky.notification.putPreferencesV2", body: preferences)
603603+ }
604604+605605+ /// Registers a push token.
606606+ public func registerPush(
607607+ serviceDid: String,
608608+ token: String,
609609+ platform: String,
610610+ appID: String,
611611+ ageRestricted: Bool? = nil
612612+ ) async throws {
613613+ var body: Parameters = [
614614+ "serviceDid": serviceDid,
615615+ "token": token,
616616+ "platform": platform,
617617+ "appId": appID
618618+ ]
619619+ if let ageRestricted { body["ageRestricted"] = ageRestricted }
620620+ let _: EmptyResponse = try await executeProcedure("app.bsky.notification.registerPush", body: body)
621621+ }
622622+623623+ /// Unregisters a push token.
624624+ public func unregisterPush(serviceDid: String, token: String, platform: String, appID: String) async throws {
625625+ let _: EmptyResponse = try await executeProcedure(
626626+ "app.bsky.notification.unregisterPush",
627627+ body: [
628628+ "serviceDid": serviceDid,
629629+ "token": token,
630630+ "platform": platform,
631631+ "appId": appID
632632+ ]
633633+ )
634634+ }
635635+636636+ // MARK: - Priority 4: Age Assurance, Unspecced, and Video
637637+638638+ /// Starts age-assurance flow.
639639+ public func beginAgeAssurance(
640640+ email: String,
641641+ language: String? = nil,
642642+ countryCode: String? = nil,
643643+ regionCode: String? = nil
644644+ ) async throws {
645645+ var body: Parameters = ["email": email]
646646+ if let language { body["language"] = language }
647647+ if let countryCode { body["countryCode"] = countryCode }
648648+ if let regionCode { body["regionCode"] = regionCode }
649649+ let _: EmptyResponse = try await executeProcedure("app.bsky.ageassurance.begin", body: body)
650650+ }
651651+652652+ /// Fetches age-assurance service config.
653653+ public func getAgeAssuranceConfig() async throws -> JSONValue {
654654+ try await executeQuery("app.bsky.ageassurance.getConfig")
655655+ }
656656+657657+ /// Fetches age-assurance state.
658658+ public func getAgeAssuranceState(countryCode: String? = nil, regionCode: String? = nil) async throws -> JSONValue {
659659+ var params: Parameters = [:]
660660+ if let countryCode { params["countryCode"] = countryCode }
661661+ if let regionCode { params["regionCode"] = regionCode }
662662+ return try await executeQuery("app.bsky.ageassurance.getState", parameters: params)
663663+ }
664664+665665+ /// Unspecced age-assurance state endpoint.
666666+ public func getUnspeccedAgeAssuranceState() async throws -> JSONValue {
667667+ try await executeQuery("app.bsky.unspecced.getAgeAssuranceState")
668668+ }
669669+670670+ /// Unspecced service config endpoint.
671671+ public func getUnspeccedConfig() async throws -> JSONValue {
672672+ try await executeQuery("app.bsky.unspecced.getConfig")
673673+ }
674674+675675+ public func getOnboardingSuggestedStarterPacks(limit: Int = 25) async throws -> JSONValue {
676676+ try await executeQuery("app.bsky.unspecced.getOnboardingSuggestedStarterPacks", parameters: ["limit": limit])
677677+ }
678678+679679+ public func getOnboardingSuggestedStarterPacksSkeleton(viewer: String? = nil, limit: Int = 25) async throws -> JSONValue {
680680+ var params: Parameters = ["limit": limit]
681681+ if let viewer { params["viewer"] = viewer }
682682+ return try await executeQuery("app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", parameters: params)
683683+ }
684684+685685+ public func getPopularFeedGenerators(limit: Int = 50, cursor: String? = nil, query: String? = nil) async throws -> JSONValue {
686686+ var params: Parameters = ["limit": limit]
687687+ if let cursor { params["cursor"] = cursor }
688688+ if let query { params["query"] = query }
689689+ return try await executeQuery("app.bsky.unspecced.getPopularFeedGenerators", parameters: params)
690690+ }
691691+692692+ public func getPostThreadV2(
693693+ anchor: String,
694694+ above: Int? = nil,
695695+ below: Int? = nil,
696696+ branchingFactor: Int? = nil,
697697+ sort: String? = nil
698698+ ) async throws -> JSONValue {
699699+ var params: Parameters = ["anchor": anchor]
700700+ if let above { params["above"] = above }
701701+ if let below { params["below"] = below }
702702+ if let branchingFactor { params["branchingFactor"] = branchingFactor }
703703+ if let sort { params["sort"] = sort }
704704+ return try await executeQuery("app.bsky.unspecced.getPostThreadV2", parameters: params)
705705+ }
706706+707707+ public func getPostThreadOtherV2(anchor: String) async throws -> JSONValue {
708708+ try await executeQuery("app.bsky.unspecced.getPostThreadOtherV2", parameters: ["anchor": anchor])
709709+ }
710710+711711+ public func getUnspeccedSuggestedFeeds(limit: Int = 50) async throws -> JSONValue {
712712+ try await executeQuery("app.bsky.unspecced.getSuggestedFeeds", parameters: ["limit": limit])
713713+ }
714714+715715+ public func getSuggestedFeedsSkeleton(viewer: String? = nil, limit: Int = 50) async throws -> JSONValue {
716716+ var params: Parameters = ["limit": limit]
717717+ if let viewer { params["viewer"] = viewer }
718718+ return try await executeQuery("app.bsky.unspecced.getSuggestedFeedsSkeleton", parameters: params)
719719+ }
720720+721721+ public func getSuggestedStarterPacks(limit: Int = 50) async throws -> JSONValue {
722722+ try await executeQuery("app.bsky.unspecced.getSuggestedStarterPacks", parameters: ["limit": limit])
723723+ }
724724+725725+ public func getSuggestedStarterPacksSkeleton(viewer: String? = nil, limit: Int = 50) async throws -> JSONValue {
726726+ var params: Parameters = ["limit": limit]
727727+ if let viewer { params["viewer"] = viewer }
728728+ return try await executeQuery("app.bsky.unspecced.getSuggestedStarterPacksSkeleton", parameters: params)
729729+ }
730730+731731+ public func getSuggestedUsers(category: String? = nil, limit: Int = 25) async throws -> JSONValue {
732732+ var params: Parameters = ["limit": limit]
733733+ if let category { params["category"] = category }
734734+ return try await executeQuery("app.bsky.unspecced.getSuggestedUsers", parameters: params)
735735+ }
736736+737737+ public func getSuggestedUsersSkeleton(viewer: String? = nil, category: String? = nil, limit: Int = 25) async throws -> JSONValue {
738738+ var params: Parameters = ["limit": limit]
739739+ if let viewer { params["viewer"] = viewer }
740740+ if let category { params["category"] = category }
741741+ return try await executeQuery("app.bsky.unspecced.getSuggestedUsersSkeleton", parameters: params)
742742+ }
743743+744744+ public func getSuggestionsSkeleton(
745745+ viewer: String? = nil,
746746+ limit: Int = 50,
747747+ cursor: String? = nil,
748748+ relativeToDid: String? = nil
749749+ ) async throws -> JSONValue {
750750+ var params: Parameters = ["limit": limit]
751751+ if let viewer { params["viewer"] = viewer }
752752+ if let cursor { params["cursor"] = cursor }
753753+ if let relativeToDid { params["relativeToDid"] = relativeToDid }
754754+ return try await executeQuery("app.bsky.unspecced.getSuggestionsSkeleton", parameters: params)
755755+ }
756756+757757+ public func getTaggedSuggestions() async throws -> JSONValue {
758758+ try await executeQuery("app.bsky.unspecced.getTaggedSuggestions")
759759+ }
760760+761761+ public func getTrendingTopics(viewer: String? = nil, limit: Int = 10) async throws -> JSONValue {
762762+ var params: Parameters = ["limit": limit]
763763+ if let viewer { params["viewer"] = viewer }
764764+ return try await executeQuery("app.bsky.unspecced.getTrendingTopics", parameters: params)
765765+ }
766766+767767+ public func getTrends(limit: Int = 10) async throws -> JSONValue {
768768+ try await executeQuery("app.bsky.unspecced.getTrends", parameters: ["limit": limit])
769769+ }
770770+771771+ public func getTrendsSkeleton(viewer: String? = nil, limit: Int = 10) async throws -> JSONValue {
772772+ var params: Parameters = ["limit": limit]
773773+ if let viewer { params["viewer"] = viewer }
774774+ return try await executeQuery("app.bsky.unspecced.getTrendsSkeleton", parameters: params)
775775+ }
776776+777777+ public func initAgeAssurance(email: String, language: String? = nil, countryCode: String? = nil) async throws {
778778+ var body: Parameters = ["email": email]
779779+ if let language { body["language"] = language }
780780+ if let countryCode { body["countryCode"] = countryCode }
781781+ let _: EmptyResponse = try await executeProcedure("app.bsky.unspecced.initAgeAssurance", body: body)
782782+ }
783783+784784+ public func searchActorsSkeleton(
785785+ query: String,
786786+ viewer: String? = nil,
787787+ typeahead: Bool? = nil,
788788+ limit: Int = 25,
789789+ cursor: String? = nil
790790+ ) async throws -> JSONValue {
791791+ var params: Parameters = ["q": query, "limit": limit]
792792+ if let viewer { params["viewer"] = viewer }
793793+ if let typeahead { params["typeahead"] = typeahead }
794794+ if let cursor { params["cursor"] = cursor }
795795+ return try await executeQuery("app.bsky.unspecced.searchActorsSkeleton", parameters: params)
796796+ }
797797+798798+ public func searchPostsSkeleton(
799799+ query: String,
800800+ sort: String? = nil,
801801+ since: String? = nil,
802802+ until: String? = nil,
803803+ mentions: String? = nil,
804804+ author: String? = nil,
805805+ lang: String? = nil,
806806+ domain: String? = nil,
807807+ url: String? = nil,
808808+ tags: [String]? = nil,
809809+ viewer: String? = nil,
810810+ limit: Int = 25,
811811+ cursor: String? = nil
812812+ ) async throws -> JSONValue {
813813+ var params: Parameters = ["q": query, "limit": limit]
814814+ if let sort { params["sort"] = sort }
815815+ if let since { params["since"] = since }
816816+ if let until { params["until"] = until }
817817+ if let mentions { params["mentions"] = mentions }
818818+ if let author { params["author"] = author }
819819+ if let lang { params["lang"] = lang }
820820+ if let domain { params["domain"] = domain }
821821+ if let url { params["url"] = url }
822822+ if let tags, !tags.isEmpty { params["tag"] = tags }
823823+ if let viewer { params["viewer"] = viewer }
824824+ if let cursor { params["cursor"] = cursor }
825825+ return try await executeQuery("app.bsky.unspecced.searchPostsSkeleton", parameters: params)
826826+ }
827827+828828+ public func searchStarterPacksSkeleton(
829829+ query: String,
830830+ viewer: String? = nil,
831831+ limit: Int = 25,
832832+ cursor: String? = nil
833833+ ) async throws -> JSONValue {
834834+ var params: Parameters = ["q": query, "limit": limit]
835835+ if let viewer { params["viewer"] = viewer }
836836+ if let cursor { params["cursor"] = cursor }
837837+ return try await executeQuery("app.bsky.unspecced.searchStarterPacksSkeleton", parameters: params)
838838+ }
839839+840840+ /// Fetches video processing status for a job.
841841+ public func getVideoJobStatus(jobID: String) async throws -> JSONValue {
842842+ try await executeQuery("app.bsky.video.getJobStatus", parameters: ["jobId": jobID])
843843+ }
844844+845845+ /// Fetches current video upload limits for the authenticated actor.
846846+ public func getVideoUploadLimits() async throws -> VideoUploadLimits {
847847+ try await executeQuery("app.bsky.video.getUploadLimits")
848848+ }
849849+850850+ /// Uploads a video blob for asynchronous processing.
851851+ public func uploadVideo(_ data: Data, mimeType: String = "video/mp4") async throws -> JSONValue {
852852+ try await executeDataProcedure(
853853+ "app.bsky.video.uploadVideo",
854854+ data: data,
855855+ contentType: mimeType
856856+ )
857857+ }
858858+}
+26
Sources/bskyKit/Configuration.swift
···11+import Foundation
22+import CoreATProtocol
33+44+public enum BskyKitConfigurationError: Error, LocalizedError, Sendable {
55+ case hostNotConfigured
66+ case invalidHostURL(String)
77+88+ public var errorDescription: String? {
99+ switch self {
1010+ case .hostNotConfigured:
1111+ return "AT Protocol host is not configured. Call setup(hostURL:accessJWT:refreshJWT:) first."
1212+ case .invalidHostURL(let value):
1313+ return "Configured AT Protocol host is not a valid URL: \(value)"
1414+ }
1515+ }
1616+}
1717+1818+@APActor
1919+func ensureHostConfigured() throws {
2020+ guard let host = APEnvironment.current.host else {
2121+ throw BskyKitConfigurationError.hostNotConfigured
2222+ }
2323+ guard URL(string: host) != nil else {
2424+ throw BskyKitConfigurationError.invalidHostURL(host)
2525+ }
2626+}
+13
Sources/bskyKit/Models/Feed.swift
···2626public struct Feeds: Codable, Sendable {
2727 public let feeds: [Feed]
2828}
2929+3030+/// Paged feed-generator response shape used by multiple endpoints.
3131+public struct FeedPageResponse: Codable, Sendable {
3232+ public let feeds: [Feed]
3333+ public let cursor: String?
3434+}
3535+3636+/// Response from app.bsky.feed.getFeedGenerator
3737+public struct FeedGeneratorResponse: Codable, Sendable {
3838+ public let view: Feed
3939+ public let isOnline: Bool?
4040+ public let isValid: Bool?
4141+}
+51
Sources/bskyKit/Models/Graph.swift
···46464747 public var id: String { did }
4848}
4949+5050+/// Response from app.bsky.graph.getRelationships
5151+public struct RelationshipsResponse: Codable, Sendable {
5252+ public let actor: String
5353+ public let relationships: [RelationshipResult]
5454+}
5555+5656+public enum RelationshipResult: Codable, Sendable {
5757+ case relationship(Relationship)
5858+ case notFound(NotFoundActor)
5959+ case unknown
6060+6161+ public init(from decoder: Decoder) throws {
6262+ if let relationship = try? Relationship(from: decoder) {
6363+ self = .relationship(relationship)
6464+ return
6565+ }
6666+ if let notFound = try? NotFoundActor(from: decoder) {
6767+ self = .notFound(notFound)
6868+ return
6969+ }
7070+ self = .unknown
7171+ }
7272+7373+ public func encode(to encoder: Encoder) throws {
7474+ switch self {
7575+ case .relationship(let relationship):
7676+ try relationship.encode(to: encoder)
7777+ case .notFound(let notFound):
7878+ try notFound.encode(to: encoder)
7979+ case .unknown:
8080+ var container = encoder.singleValueContainer()
8181+ try container.encode([String: String]())
8282+ }
8383+ }
8484+}
8585+8686+public struct Relationship: Codable, Sendable {
8787+ public let did: String
8888+ public let following: String?
8989+ public let followedBy: String?
9090+ public let blocking: String?
9191+ public let blockedBy: String?
9292+ public let blockingByList: String?
9393+ public let blockedByList: String?
9494+}
9595+9696+public struct NotFoundActor: Codable, Sendable {
9797+ public let actor: String
9898+ public let notFound: Bool
9999+}
+59
Sources/bskyKit/Models/JSONValue.swift
···11+import Foundation
22+33+public enum JSONValue: Codable, Sendable, Equatable {
44+ case string(String)
55+ case number(Double)
66+ case bool(Bool)
77+ case array([JSONValue])
88+ case object([String: JSONValue])
99+ case null
1010+1111+ public init(from decoder: Decoder) throws {
1212+ let container = try decoder.singleValueContainer()
1313+1414+ if container.decodeNil() {
1515+ self = .null
1616+ return
1717+ }
1818+ if let value = try? container.decode(Bool.self) {
1919+ self = .bool(value)
2020+ return
2121+ }
2222+ if let value = try? container.decode(Double.self) {
2323+ self = .number(value)
2424+ return
2525+ }
2626+ if let value = try? container.decode(String.self) {
2727+ self = .string(value)
2828+ return
2929+ }
3030+ if let value = try? container.decode([JSONValue].self) {
3131+ self = .array(value)
3232+ return
3333+ }
3434+ if let value = try? container.decode([String: JSONValue].self) {
3535+ self = .object(value)
3636+ return
3737+ }
3838+3939+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
4040+ }
4141+4242+ public func encode(to encoder: Encoder) throws {
4343+ var container = encoder.singleValueContainer()
4444+ switch self {
4545+ case .string(let value):
4646+ try container.encode(value)
4747+ case .number(let value):
4848+ try container.encode(value)
4949+ case .bool(let value):
5050+ try container.encode(value)
5151+ case .array(let value):
5252+ try container.encode(value)
5353+ case .object(let value):
5454+ try container.encode(value)
5555+ case .null:
5656+ try container.encodeNil()
5757+ }
5858+ }
5959+}
+31-68
Sources/bskyKit/Models/PostThread.swift
···11-//
22-// PostThread.swift
33-// bskyKit
44-//
55-// Created by Thomas Rademaker on 01/02/2026.
66-//
77-81import Foundation
92103/// Response from app.bsky.feed.getPostThread
114public struct PostThreadResponse: Codable, Sendable {
1212- public let thread: ThreadViewPost
1313-}
1414-1515-/// A post in a thread with parent/replies context
1616-/// Uses class for recursive structure support
1717-public final class ThreadViewPost: Codable, Sendable, Identifiable {
1818- public let type: String?
1919- public let post: Post
2020- public let parent: ThreadParent?
2121- public let replies: [ThreadReply]?
2222-2323- public var id: String { post.uri ?? "" }
2424-2525- enum CodingKeys: String, CodingKey {
2626- case type = "$type"
2727- case post, parent, replies
2828- }
55+ public let thread: ThreadNode
2963030- public init(type: String?, post: Post, parent: ThreadParent?, replies: [ThreadReply]?) {
3131- self.type = type
3232- self.post = post
3333- self.parent = parent
3434- self.replies = replies
77+ public var threadPost: ThreadViewPost? {
88+ if case .post(let post) = thread {
99+ return post
1010+ }
1111+ return nil
3512 }
3613}
37143838-/// Parent of a thread post (can be another post or blocked/not found)
3939-public indirect enum ThreadParent: Codable, Sendable {
1515+public enum ThreadNode: Codable, Sendable {
4016 case post(ThreadViewPost)
4117 case notFound(NotFoundPost)
4218 case blocked(BlockedPost)
43194444- public init(from decoder: Decoder) throws {
4545- let container = try decoder.container(keyedBy: CodingKeys.self)
4646- let type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
4747-4848- if type.contains("notFoundPost") {
4949- self = .notFound(try NotFoundPost(from: decoder))
5050- } else if type.contains("blockedPost") {
5151- self = .blocked(try BlockedPost(from: decoder))
5252- } else {
5353- self = .post(try ThreadViewPost(from: decoder))
5454- }
5555- }
5656-5757- public func encode(to encoder: Encoder) throws {
5858- switch self {
5959- case .post(let threadPost):
6060- try threadPost.encode(to: encoder)
6161- case .notFound(let notFound):
6262- try notFound.encode(to: encoder)
6363- case .blocked(let blocked):
6464- try blocked.encode(to: encoder)
6565- }
6666- }
6767-6820 enum CodingKeys: String, CodingKey {
6921 case type = "$type"
7022 }
7171-}
7272-7373-/// Reply to a thread post (can be another post or blocked/not found)
7474-public indirect enum ThreadReply: Codable, Sendable {
7575- case post(ThreadViewPost)
7676- case notFound(NotFoundPost)
7777- case blocked(BlockedPost)
78237924 public init(from decoder: Decoder) throws {
8025 let container = try decoder.container(keyedBy: CodingKeys.self)
···91369237 public func encode(to encoder: Encoder) throws {
9338 switch self {
9494- case .post(let threadPost):
9595- try threadPost.encode(to: encoder)
9696- case .notFound(let notFound):
9797- try notFound.encode(to: encoder)
9898- case .blocked(let blocked):
9999- try blocked.encode(to: encoder)
3939+ case .post(let post):
4040+ try post.encode(to: encoder)
4141+ case .notFound(let post):
4242+ try post.encode(to: encoder)
4343+ case .blocked(let post):
4444+ try post.encode(to: encoder)
10045 }
10146 }
4747+}
4848+4949+/// A post in a thread with parent/replies context.
5050+public final class ThreadViewPost: Codable, Sendable, Identifiable {
5151+ public let type: String?
5252+ public let post: Post
5353+ public let parent: ThreadNode?
5454+ public let replies: [ThreadNode]?
5555+5656+ public var id: String { post.uri }
1025710358 enum CodingKeys: String, CodingKey {
10459 case type = "$type"
6060+ case post, parent, replies
6161+ }
6262+6363+ public init(type: String?, post: Post, parent: ThreadNode?, replies: [ThreadNode]?) {
6464+ self.type = type
6565+ self.post = post
6666+ self.parent = parent
6767+ self.replies = replies
10568 }
10669}
10770
+15
Sources/bskyKit/Models/Posts.swift
···1111public struct Posts: Codable, Sendable {
1212 public let posts: [Post]
1313}
1414+1515+/// Response from app.bsky.feed.searchPosts
1616+public struct SearchPostsResponse: Codable, Sendable {
1717+ public let posts: [Post]
1818+ public let cursor: String?
1919+ public let hitsTotal: Int?
2020+}
2121+2222+/// Response from app.bsky.feed.getQuotes
2323+public struct QuotesResponse: Codable, Sendable {
2424+ public let uri: String
2525+ public let cid: String?
2626+ public let posts: [Post]
2727+ public let cursor: String?
2828+}
+7
Sources/bskyKit/Models/SearchActors.swift
···1313 public let cursor: String?
1414}
15151616+/// Response from app.bsky.actor.getSuggestions
1717+public struct ActorSuggestionsResponse: Codable, Sendable {
1818+ public let actors: [ActorProfile]
1919+ public let cursor: String?
2020+ public let recId: Int?
2121+}
2222+1623/// Response from app.bsky.actor.searchActorsTypeahead
1724public struct SearchActorsTypeaheadResult: Codable, Sendable {
1825 public let actors: [ActorProfile]
+200-183
Sources/bskyKit/Models/Timeline.swift
···11-//
22-// Timeline.swift
33-// bskyKit
44-//
55-// Created by Thomas Rademaker on 10/11/25.
66-//
77-81import Foundation
92103public struct Timeline: Codable, Sendable {
114 public var feed: [TimelineItem]
1212- public var cursor: String
55+ public var cursor: String?
136}
1471515-public struct TimelineItem: Codable, Sendable {
88+public struct TimelineItem: Codable, Sendable, Identifiable, Equatable {
169 public let post: Post
1710 public let reply: Reply?
1818-}
1919-/*
2020-{
2121- "post": {
2222- "uri": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.feed.post/3l7r3os55mj2r",
2323- "cid": "bafyreign5fpvfsfj6xriqbdkp3pwf5lmcfzvkedxy6yi3csxmwfkf7cdiu",
2424- "author": {
2525- "did": "did:plc:gkqxrdozmfap5ehgd5xlhem2",
2626- "handle": "atprotesting123.bsky.social",
2727- "viewer": {
2828- "muted": false,
2929- "blockedBy": false,
3030- "following": "at://did:plc:aq5iwu4gjdcg2hq53llism3x/app.bsky.graph.follow/3l7oxdij6km2a",
3131- "followedBy": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.graph.follow/3kcyrue74z32v"
3232- },
3333- "labels": [],
3434- "createdAt": "2023-10-30T21:44:11.344Z"
3535- },
3636- "record": {
3737- "$type": "app.bsky.feed.post",
3838- "createdAt": "2024-10-30T21:30:34.509Z",
3939- "facets": [
4040- {
4141- "features": [
4242- {
4343- "$type": "app.bsky.richtext.facet#link",
4444- "uri": "https://x.com"
4545- }
4646- ],
4747- "index": {
4848- "byteEnd": 5,
4949- "byteStart": 0
5050- }
5151- }
5252- ],
5353- "langs": [
5454- "en"
5555- ],
5656- "text": "x.com"
5757- },
5858- "replyCount": 0,
5959- "repostCount": 0,
6060- "likeCount": 0,
6161- "quoteCount": 0,
6262- "indexedAt": "2024-10-30T21:30:34.509Z",
6363- "viewer": {
6464- "threadMuted": false,
6565- "embeddingDisabled": false
6666- },
6767- "labels": []
6868- }
6969- },*/
7070-7171-extension TimelineItem: Equatable {
7272- public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool {
7373- lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid
7474- }
7575-}
1111+ public let reason: TimelineReason?
1212+ public let feedContext: String?
1313+ public let reqId: String?
76147777-extension TimelineItem: Identifiable {
7878- /// Stable identifier based on post URI and CID
7915 public var id: String {
8080- "\(post.uri ?? "")-\(post.cid ?? "")"
1616+ "\(post.uri)-\(post.cid)"
8117 }
8282-}
83188484-public struct Post: Codable, Sendable {
8585- public let uri: String?
8686- public let cid: String?
8787- public let author: Author
8888- public let record: Record
8989- public let facets: PostFacet?
9090- public let replyCount: Int
9191- public let repostCount: Int
9292- public let likeCount: Int
9393- public let indexedAt: String
9494- public let viewer: Viewer
9595- public let labels: [String]
9696- public let embed: Embed?
9797-}
9898-9999-public struct PostFacet: Codable, Sendable {
100100- public let facets: [Facet]
101101- public let createdAt: Date
1919+ public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool {
2020+ lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid
2121+ }
10222}
10323104104-public struct Facet: Codable, Sendable {
105105- public let index: FacetIndex
106106- public let features: [FacetFeature]
107107-108108-}
2424+public enum TimelineReason: Codable, Sendable, Equatable {
2525+ case repost(ReasonRepost)
2626+ case pin(ReasonPin)
2727+ case unknown(String)
10928110110-public struct FacetFeature: Codable, Sendable {
111111- public let uri: String?
112112- public let type: FacetType
113113-11429 enum CodingKeys: String, CodingKey {
115115- case uri
11630 case type = "$type"
11731 }
118118-}
11932120120-public enum FacetType: Codable, Sendable {
121121- case link(String)
122122- case unknown(String)
123123-12433 public init(from decoder: Decoder) throws {
125125- let container = try decoder.singleValueContainer()
126126- let value = try container.decode(String.self)
127127-128128- switch value {
129129- case "app.bsky.richtext.facet#link": self = .link(value)
130130- default: self = .unknown(value)
3434+ let container = try decoder.container(keyedBy: CodingKeys.self)
3535+ let type = try container.decode(String.self, forKey: .type)
3636+3737+ switch type {
3838+ case "app.bsky.feed.defs#reasonRepost":
3939+ self = .repost(try ReasonRepost(from: decoder))
4040+ case "app.bsky.feed.defs#reasonPin":
4141+ self = .pin(try ReasonPin(from: decoder))
4242+ default:
4343+ self = .unknown(type)
13144 }
13245 }
133133-4646+13447 public func encode(to encoder: Encoder) throws {
135135- var container = encoder.singleValueContainer()
13648 switch self {
137137- case .link(let value), .unknown(let value):
138138- try container.encode(value)
4949+ case .repost(let reason):
5050+ try reason.encode(to: encoder)
5151+ case .pin(let reason):
5252+ try reason.encode(to: encoder)
5353+ case .unknown(let type):
5454+ var container = encoder.container(keyedBy: CodingKeys.self)
5555+ try container.encode(type, forKey: .type)
13956 }
14057 }
14158}
14259143143-public struct FacetIndex: Codable, Sendable {
144144- public let byteEnd: Int
145145- public let byteStart: Int
6060+public struct ReasonRepost: Codable, Sendable, Equatable {
6161+ public let by: Author
6262+ public let indexedAt: Date?
6363+6464+ public static func == (lhs: ReasonRepost, rhs: ReasonRepost) -> Bool {
6565+ lhs.by.did == rhs.by.did &&
6666+ lhs.by.handle == rhs.by.handle &&
6767+ lhs.indexedAt == rhs.indexedAt
6868+ }
6969+}
7070+7171+public struct ReasonPin: Codable, Sendable, Equatable {
7272+ public let by: Author
7373+ public let indexedAt: Date?
7474+7575+ public static func == (lhs: ReasonPin, rhs: ReasonPin) -> Bool {
7676+ lhs.by.did == rhs.by.did &&
7777+ lhs.by.handle == rhs.by.handle &&
7878+ lhs.indexedAt == rhs.indexedAt
7979+ }
8080+}
8181+8282+public struct Post: Codable, Sendable {
8383+ public let uri: String
8484+ public let cid: String
8585+ public let author: Author
8686+ public let record: Record
8787+ public let embed: Embed?
8888+ public let bookmarkCount: Int?
8989+ public let replyCount: Int?
9090+ public let repostCount: Int?
9191+ public let likeCount: Int?
9292+ public let quoteCount: Int?
9393+ public let indexedAt: Date
9494+ public let viewer: FeedViewer?
9595+ public let labels: [AuthorLabels]?
9696+ public let threadgate: ThreadgateView?
9797+}
9898+9999+public struct FeedViewer: Codable, Sendable {
100100+ public let repost: String?
101101+ public let like: String?
102102+ public let bookmarked: Bool?
103103+ public let threadMuted: Bool?
104104+ public let replyDisabled: Bool?
105105+ public let embeddingDisabled: Bool?
106106+ public let pinned: Bool?
146107}
147108148109public struct Embed: Codable, Sendable {
149149- public let type: String
110110+ public let type: String?
150111 public let images: [EmbeddedMedia]?
151112 public let media: Media?
152113 public let record: EmbedRecord?
153114 public let external: EmbedExternal?
154154-115115+155116 enum CodingKeys: String, CodingKey {
156117 case images, media, record, external
157118 case type = "$type"
···161122public struct EmbedExternal: Codable, Sendable {
162123 public let uri: String?
163124 public let thumb: TimelineImage?
164164- public let title: String
165165- public let externalDescription: String
166166-125125+ public let title: String?
126126+ public let externalDescription: String?
127127+167128 enum CodingKeys: String, CodingKey {
168129 case uri, thumb, title
169130 case externalDescription = "description"
···177138 public let cid: String?
178139 public let author: Author?
179140 public let value: EmbedRecordValue?
180180-// public let labels: [String]
181181-// public let indexedAt: Date
182182-// public let embeds: [String] // TODO: This isn't correct
183183-184184-141141+185142 enum CodingKeys: String, CodingKey {
186143 case type = "$type"
187187- case record, uri, cid, author, value/*, labels, indexedAt, embeds*/
144144+ case record, uri, cid, author, value
188145 }
189146}
190147191148public struct EmbedRecordValue: Codable, Sendable {
192192- public let text: String
193193- public let type: String
149149+ public let type: String?
150150+ public let text: String?
194151 public let langs: [String]?
195152 public let reply: ReplyDetail?
196196- public let createdAt: String
197197-153153+ public let createdAt: Date?
154154+198155 enum CodingKeys: String, CodingKey {
199156 case type = "$type"
200200- case langs, reply, createdAt, text
157157+ case text, langs, reply, createdAt
201158 }
202159}
203160204161public struct Media: Codable, Sendable {
205205- public let type: String
162162+ public let type: String?
206163 public let images: [EmbeddedMedia]?
207207-164164+208165 enum CodingKeys: String, CodingKey {
209166 case type = "$type"
210167 case images
211168 }
212169}
213170214214-public enum EmbedType: String, Codable, Sendable {
215215- case image = "app.bsky.embed.images"
216216- case recordWithMedia = "app.bsky.embed.recordWithMedia"
217217- case external = "app.bsky.embed.external"
218218- case record = "app.bsky.embed.record"
219219-}
220220-221171public enum TimelineImage: Codable, Sendable, Identifiable {
222172 case string(String)
223173 case image(EmbeddedImage)
224224-174174+225175 public init(from decoder: Decoder) throws {
226176 let container = try decoder.singleValueContainer()
227227-228177 if let string = try? container.decode(String.self) {
229178 self = .string(string)
230179 return
231180 }
232232-233181 if let image = try? container.decode(EmbeddedImage.self) {
234182 self = .image(image)
235183 return
236184 }
237237-238238- throw DecodingError.typeMismatch(TimelineImage.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for MyProperty"))
185185+ throw DecodingError.typeMismatch(
186186+ TimelineImage.self,
187187+ DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected string or embedded image")
188188+ )
239189 }
240240-190190+241191 public func encode(to encoder: Encoder) throws {
242192 var container = encoder.singleValueContainer()
243193 switch self {
···247197 try container.encode(image)
248198 }
249199 }
250250-251251- /// Stable identifier based on content
200200+252201 public var id: String {
253202 switch self {
254203 case .string(let value):
255204 return value
256256- case .image(let img):
257257- return "\(img.type)-\(img.size)"
205205+ case .image(let image):
206206+ return "\(image.type ?? "image")-\(image.size ?? -1)"
258207 }
259208 }
260209}
···262211public struct EmbeddedMedia: Codable, Sendable {
263212 public let thumb: TimelineImage?
264213 public let fullsize: String?
265265- public let alt: String
214214+ public let alt: String?
266215 public let aspectRatio: EmbedImageAspectRatio?
267216 public let image: TimelineImage?
268217}
269218270219public struct EmbeddedImage: Codable, Sendable {
271271- public let type: String
272272- public let ref: [String : String]
273273- public let mimeType: String
274274- public let size: Int
275275-220220+ public let type: String?
221221+ public let ref: [String: String]?
222222+ public let mimeType: String?
223223+ public let size: Int?
224224+276225 enum CodingKeys: String, CodingKey {
277226 case type = "$type"
278227 case ref, mimeType, size
···294243 public let handle: String
295244 public let displayName: String?
296245 public let avatar: String?
297297- public let viewer: Viewer
298298- public let labels: [AuthorLabels]
246246+ public let viewer: Viewer?
247247+ public let labels: [AuthorLabels]?
248248+ public let createdAt: Date?
299249}
300250301251public struct AuthorLabels: Codable, Sendable {
···307257}
308258309259public struct Record: Codable, Sendable {
310310- public let text: String
311311- public let type: String
260260+ public let type: String?
261261+ public let text: String?
312262 public let langs: [String]?
313263 public let reply: ReplyDetail?
314314- public let createdAt: String
264264+ public let createdAt: Date?
315265 public let embed: Embed?
316266 public let facets: [Facet]?
317317-267267+268268+ enum CodingKeys: String, CodingKey {
269269+ case type = "$type"
270270+ case text, langs, reply, createdAt, embed, facets
271271+ }
272272+}
273273+274274+public struct Facet: Codable, Sendable {
275275+ public let index: FacetIndex
276276+ public let features: [FacetFeature]
277277+}
278278+279279+public struct FacetFeature: Codable, Sendable {
280280+ public let uri: String?
281281+ public let did: String?
282282+ public let tag: String?
283283+ public let type: FacetType
284284+318285 enum CodingKeys: String, CodingKey {
286286+ case uri
287287+ case did
288288+ case tag
319289 case type = "$type"
320320- case langs, reply, createdAt, embed, text, facets
321290 }
322291}
323292293293+public enum FacetType: Codable, Sendable {
294294+ case link
295295+ case mention
296296+ case tag
297297+ case unknown(String)
298298+299299+ public init(from decoder: Decoder) throws {
300300+ let container = try decoder.singleValueContainer()
301301+ let value = try container.decode(String.self)
302302+ switch value {
303303+ case "app.bsky.richtext.facet#link":
304304+ self = .link
305305+ case "app.bsky.richtext.facet#mention":
306306+ self = .mention
307307+ case "app.bsky.richtext.facet#tag":
308308+ self = .tag
309309+ default:
310310+ self = .unknown(value)
311311+ }
312312+ }
313313+314314+ public func encode(to encoder: Encoder) throws {
315315+ var container = encoder.singleValueContainer()
316316+ switch self {
317317+ case .link:
318318+ try container.encode("app.bsky.richtext.facet#link")
319319+ case .mention:
320320+ try container.encode("app.bsky.richtext.facet#mention")
321321+ case .tag:
322322+ try container.encode("app.bsky.richtext.facet#tag")
323323+ case .unknown(let value):
324324+ try container.encode(value)
325325+ }
326326+ }
327327+}
328328+329329+public struct FacetIndex: Codable, Sendable {
330330+ public let byteEnd: Int
331331+ public let byteStart: Int
332332+}
333333+324334public struct ReplyDetail: Codable, Sendable {
325335 public let root: UnpopulatedPost
326336 public let parent: UnpopulatedPost
···332342}
333343334344public struct Root: Codable, Sendable {
335335- public let type: String
345345+ public let type: String?
336346 public let uri: String?
337347 public let cid: String?
338348 public let author: Author
339349 public let record: Record
340340- public let replyCount: Int
341341- public let repostCount: Int
342342- public let likeCount: Int
343343- public let indexedAt: String
344344- public let viewer: Viewer
345345- public let labels: [String]
346346-350350+ public let replyCount: Int?
351351+ public let repostCount: Int?
352352+ public let likeCount: Int?
353353+ public let quoteCount: Int?
354354+ public let indexedAt: Date?
355355+ public let viewer: FeedViewer?
356356+ public let labels: [AuthorLabels]?
357357+347358 enum CodingKeys: String, CodingKey {
348359 case type = "$type"
349349- case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels
360360+ case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels
350361 }
351362}
352363353364public struct Parent: Codable, Sendable {
354354- public let type: String
365365+ public let type: String?
355366 public let uri: String?
356367 public let cid: String?
357368 public let author: Author
358369 public let record: Record
359359- public let replyCount: Int
360360- public let repostCount: Int
361361- public let likeCount: Int
362362- public let indexedAt: String
363363- public let viewer: Viewer
364364- public let labels: [String]
365365-370370+ public let replyCount: Int?
371371+ public let repostCount: Int?
372372+ public let likeCount: Int?
373373+ public let quoteCount: Int?
374374+ public let indexedAt: Date?
375375+ public let viewer: FeedViewer?
376376+ public let labels: [AuthorLabels]?
377377+366378 enum CodingKeys: String, CodingKey {
367379 case type = "$type"
368368- case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels
380380+ case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels
369381 }
370382}
383383+384384+public struct ThreadgateView: Codable, Sendable {
385385+ public let uri: String?
386386+ public let cid: String?
387387+}
+10
Sources/bskyKit/Models/Video.swift
···11+import Foundation
22+33+/// Response from app.bsky.video.getUploadLimits
44+public struct VideoUploadLimits: Codable, Sendable {
55+ public let canUpload: Bool
66+ public let remainingDailyVideos: Int?
77+ public let remainingDailyBytes: Int?
88+ public let message: String?
99+ public let error: String?
1010+}
+48
Sources/bskyKit/Models/Viewer.swift
···3131 self.mutedByList = mutedByList
3232 self.blockingByList = blockingByList
3333 }
3434+3535+ enum CodingKeys: String, CodingKey {
3636+ case muted
3737+ case blockedBy
3838+ case following
3939+ case followedBy
4040+ case blocking
4141+ case mutedByList
4242+ case blockingByList
4343+ }
4444+4545+ public init(from decoder: Decoder) throws {
4646+ let container = try decoder.container(keyedBy: CodingKeys.self)
4747+ muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
4848+ blockedBy = try container.decodeIfPresent(Bool.self, forKey: .blockedBy)
4949+ following = try container.decodeIfPresent(String.self, forKey: .following)
5050+ followedBy = try container.decodeIfPresent(String.self, forKey: .followedBy)
5151+ blocking = try container.decodeIfPresent(String.self, forKey: .blocking)
5252+ mutedByList = try container.decodeListURIIfPresent(forKey: .mutedByList)
5353+ blockingByList = try container.decodeListURIIfPresent(forKey: .blockingByList)
5454+ }
5555+5656+ public func encode(to encoder: Encoder) throws {
5757+ var container = encoder.container(keyedBy: CodingKeys.self)
5858+ try container.encodeIfPresent(muted, forKey: .muted)
5959+ try container.encodeIfPresent(blockedBy, forKey: .blockedBy)
6060+ try container.encodeIfPresent(following, forKey: .following)
6161+ try container.encodeIfPresent(followedBy, forKey: .followedBy)
6262+ try container.encodeIfPresent(blocking, forKey: .blocking)
6363+ try container.encodeIfPresent(mutedByList, forKey: .mutedByList)
6464+ try container.encodeIfPresent(blockingByList, forKey: .blockingByList)
6565+ }
6666+}
6767+6868+private struct ViewerListReference: Decodable {
6969+ let uri: String?
7070+}
7171+7272+private extension KeyedDecodingContainer where Key: CodingKey {
7373+ func decodeListURIIfPresent(forKey key: Key) throws -> String? {
7474+ if let uri = try decodeIfPresent(String.self, forKey: key) {
7575+ return uri
7676+ }
7777+ if let list = try decodeIfPresent(ViewerListReference.self, forKey: key) {
7878+ return list.uri
7979+ }
8080+ return nil
8181+ }
3482}
+46-7
Sources/bskyKit/RepoAPI.swift
···1111/// API endpoints for com.atproto.repo.* lexicons
1212enum RepoAPI: Sendable {
1313 case createRecord(body: Data)
1414+ case putRecord(body: Data)
1515+ case applyWrites(body: Data)
1416 case deleteRecord(body: Data)
1717+ case describeRepo(repo: String)
1518 case getRecord(repo: String, collection: String, rkey: String)
1619 case listRecords(repo: String, collection: String, limit: Int, cursor: String?)
1717- // Note: uploadBlob requires CoreATProtocol updates - deferred
2020+ case listMissingBlobs(limit: Int, cursor: String?)
2121+ case importRepo(data: Data)
2222+ case uploadBlob(data: Data, mimeType: String)
1823}
19242025extension RepoAPI: EndpointType {
2126 public var baseURL: URL {
2227 get async {
2323- guard let host = await APEnvironment.current.host else { fatalError("Host not set.") }
2424- guard let url = URL(string: host) else { fatalError("RepoAPI baseURL not configured.") }
2828+ guard let host = await APEnvironment.current.host,
2929+ let url = URL(string: host) else {
3030+ return URL(string: "https://invalid.invalid")!
3131+ }
2532 return url
2633 }
2734 }
···2936 var path: String {
3037 switch self {
3138 case .createRecord: "/xrpc/com.atproto.repo.createRecord"
3939+ case .putRecord: "/xrpc/com.atproto.repo.putRecord"
4040+ case .applyWrites: "/xrpc/com.atproto.repo.applyWrites"
3241 case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord"
4242+ case .describeRepo: "/xrpc/com.atproto.repo.describeRepo"
3343 case .getRecord: "/xrpc/com.atproto.repo.getRecord"
3444 case .listRecords: "/xrpc/com.atproto.repo.listRecords"
4545+ case .listMissingBlobs: "/xrpc/com.atproto.repo.listMissingBlobs"
4646+ case .importRepo: "/xrpc/com.atproto.repo.importRepo"
4747+ case .uploadBlob: "/xrpc/com.atproto.repo.uploadBlob"
3548 }
3649 }
37503851 var httpMethod: HTTPMethod {
3952 switch self {
4040- case .createRecord, .deleteRecord:
5353+ case .createRecord, .putRecord, .applyWrites, .deleteRecord, .importRepo, .uploadBlob:
4154 return .post
4242- case .getRecord, .listRecords:
5555+ case .describeRepo, .getRecord, .listRecords, .listMissingBlobs:
4356 return .get
4457 }
4558 }
46594760 var task: HTTPTask {
4861 switch self {
4949- case .createRecord(let body), .deleteRecord(let body):
6262+ case .createRecord(let body), .putRecord(let body), .applyWrites(let body), .deleteRecord(let body):
5063 return .requestParameters(encoding: .jsonDataEncoding(data: body))
51646565+ case .importRepo(let data), .uploadBlob(let data, _):
6666+ return .requestParameters(encoding: .jsonDataEncoding(data: data))
6767+6868+ case .describeRepo(let repo):
6969+ return .requestParameters(encoding: .urlEncoding(parameters: [
7070+ "repo": repo
7171+ ]))
7272+5273 case .getRecord(let repo, let collection, let rkey):
5374 return .requestParameters(encoding: .urlEncoding(parameters: [
5475 "repo": repo,
···6081 var params: Parameters = ["repo": repo, "collection": collection, "limit": limit]
6182 if let cursor { params["cursor"] = cursor }
6283 return .requestParameters(encoding: .urlEncoding(parameters: params))
8484+8585+ case .listMissingBlobs(let limit, let cursor):
8686+ var params: Parameters = ["limit": limit]
8787+ if let cursor { params["cursor"] = cursor }
8888+ return .requestParameters(encoding: .urlEncoding(parameters: params))
6389 }
6490 }
65916692 var headers: HTTPHeaders? {
6767- nil
9393+ switch self {
9494+ case .importRepo:
9595+ return [
9696+ "Content-Type": "application/vnd.ipld.car",
9797+ "Accept": "application/json"
9898+ ]
9999+ case .uploadBlob(_, let mimeType):
100100+ return [
101101+ "Content-Type": mimeType,
102102+ "Accept": "application/json"
103103+ ]
104104+ default:
105105+ return nil
106106+ }
68107 }
69108}
+183-8
Sources/bskyKit/RepoService.swift
···19192020 public init() {}
21212222+ private func execute<T: Decodable>(_ endpoint: RepoAPI) async throws -> T {
2323+ try ensureHostConfigured()
2424+ return try await router.execute(endpoint)
2525+ }
2626+2227 // MARK: - Record Operations
23282429 /// Creates a new record in the repository
···3641 if let rkey { body["rkey"] = rkey }
37423843 let data = try JSONSerialization.data(withJSONObject: body)
3939- return try await router.execute(.createRecord(body: data))
4444+ return try await execute(.createRecord(body: data))
4545+ }
4646+4747+ /// Writes a record, creating or updating it for the given record key.
4848+ public func putRecord(
4949+ repo: String,
5050+ collection: String,
5151+ rkey: String,
5252+ record: [String: Any],
5353+ validate: Bool? = nil,
5454+ swapRecord: String? = nil,
5555+ swapCommit: String? = nil
5656+ ) async throws -> PutRecordResponse {
5757+ var body: [String: Any] = [
5858+ "repo": repo,
5959+ "collection": collection,
6060+ "rkey": rkey,
6161+ "record": record
6262+ ]
6363+ if let validate { body["validate"] = validate }
6464+ if let swapRecord { body["swapRecord"] = swapRecord }
6565+ if let swapCommit { body["swapCommit"] = swapCommit }
6666+6767+ let data = try JSONSerialization.data(withJSONObject: body)
6868+ return try await execute(.putRecord(body: data))
6969+ }
7070+7171+ /// Applies a batch of create/update/delete writes in a single transaction.
7272+ public func applyWrites(
7373+ repo: String,
7474+ writes: [WriteOperation],
7575+ validate: Bool? = nil,
7676+ swapCommit: String? = nil
7777+ ) async throws -> ApplyWritesResponse {
7878+ var body: [String: Any] = [
7979+ "repo": repo,
8080+ "writes": writes.map { $0.toRecord() }
8181+ ]
8282+ if let validate { body["validate"] = validate }
8383+ if let swapCommit { body["swapCommit"] = swapCommit }
8484+8585+ let data = try JSONSerialization.data(withJSONObject: body)
8686+ return try await execute(.applyWrites(body: data))
4087 }
41884289 /// Deletes a record from the repository
···5198 "rkey": rkey
5299 ]
53100 let data = try JSONSerialization.data(withJSONObject: body)
5454- let _: EmptyResponse = try await router.execute(.deleteRecord(body: data))
101101+ let _: EmptyResponse = try await execute(.deleteRecord(body: data))
102102+ }
103103+104104+ /// Describes repository metadata, collection list, and DID document.
105105+ public func describeRepo(repo: String) async throws -> DescribeRepoResponse {
106106+ try await execute(.describeRepo(repo: repo))
55107 }
5610857109 /// Gets a single record
···60112 collection: String,
61113 rkey: String
62114 ) async throws -> GetRecordResponse {
6363- try await router.execute(.getRecord(repo: repo, collection: collection, rkey: rkey))
115115+ try await execute(.getRecord(repo: repo, collection: collection, rkey: rkey))
64116 }
6511766118 /// Lists records in a collection
···70122 limit: Int = 50,
71123 cursor: String? = nil
72124 ) async throws -> ListRecordsResponse {
7373- try await router.execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor))
125125+ try await execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor))
126126+ }
127127+128128+ /// Lists missing blobs for import/migration workflows.
129129+ public func listMissingBlobs(limit: Int = 500, cursor: String? = nil) async throws -> ListMissingBlobsResponse {
130130+ try await execute(.listMissingBlobs(limit: limit, cursor: cursor))
131131+ }
132132+133133+ /// Imports a repository archive (CAR format).
134134+ public func importRepo(car: Data) async throws {
135135+ let _: EmptyResponse = try await execute(.importRepo(data: car))
74136 }
751377676- // Note: uploadBlob deferred until CoreATProtocol is updated
138138+ /// Uploads a blob to be referenced by later record writes.
139139+ public func uploadBlob(data: Data, mimeType: String) async throws -> BlobResponse {
140140+ try await execute(.uploadBlob(data: data, mimeType: mimeType))
141141+ }
7714278143 // MARK: - High-Level Operations
79144···173238 public let cid: String
174239}
175240241241+public struct CommitMeta: Codable, Sendable {
242242+ public let cid: String
243243+ public let rev: String
244244+}
245245+246246+public struct PutRecordResponse: Codable, Sendable {
247247+ public let uri: String
248248+ public let cid: String
249249+ public let commit: CommitMeta?
250250+ public let validationStatus: String?
251251+}
252252+253253+public struct ApplyWritesResponse: Codable, Sendable {
254254+ public let commit: CommitMeta?
255255+ public let results: [ApplyWritesResult]?
256256+}
257257+258258+public enum ApplyWritesResult: Codable, Sendable {
259259+ case create(CreateRecordResponse)
260260+ case update(CreateRecordResponse)
261261+ case delete
262262+ case unknown
263263+264264+ public init(from decoder: Decoder) throws {
265265+ if let result = try? CreateRecordResponse(from: decoder) {
266266+ self = .create(result)
267267+ return
268268+ }
269269+270270+ let container = try decoder.singleValueContainer()
271271+ if (try? container.decode([String: String].self))?.isEmpty == true {
272272+ self = .delete
273273+ return
274274+ }
275275+276276+ self = .unknown
277277+ }
278278+279279+ public func encode(to encoder: Encoder) throws {
280280+ switch self {
281281+ case .create(let response), .update(let response):
282282+ try response.encode(to: encoder)
283283+ case .delete:
284284+ var container = encoder.singleValueContainer()
285285+ try container.encode([String: String]())
286286+ case .unknown:
287287+ var container = encoder.singleValueContainer()
288288+ try container.encode([String: String]())
289289+ }
290290+ }
291291+}
292292+176293public struct GetRecordResponse: Codable, Sendable {
177294 public let uri: String
178295 public let cid: String?
···195312 public let cursor: String?
196313}
197314315315+public struct DescribeRepoResponse: Codable, Sendable {
316316+ public let handle: String
317317+ public let did: String
318318+ public let didDoc: JSONValue?
319319+ public let collections: [String]
320320+ public let handleIsCorrect: Bool
321321+}
322322+323323+public struct ListMissingBlobsResponse: Codable, Sendable {
324324+ public let cursor: String?
325325+ public let blobs: [MissingBlob]
326326+}
327327+328328+public struct MissingBlob: Codable, Sendable {
329329+ public let cid: String
330330+ public let recordUri: String
331331+}
332332+198333public struct RecordItem: Codable, Sendable {
199334 public let uri: String
200335 public let cid: String
···225360 }
226361}
227362363363+public enum WriteOperation {
364364+ case create(collection: String, rkey: String? = nil, value: [String: Any])
365365+ case update(collection: String, rkey: String, value: [String: Any])
366366+ case delete(collection: String, rkey: String)
367367+368368+ fileprivate func toRecord() -> [String: Any] {
369369+ switch self {
370370+ case .create(let collection, let rkey, let value):
371371+ var record: [String: Any] = [
372372+ "$type": "com.atproto.repo.applyWrites#create",
373373+ "collection": collection,
374374+ "value": value
375375+ ]
376376+ if let rkey { record["rkey"] = rkey }
377377+ return record
378378+ case .update(let collection, let rkey, let value):
379379+ return [
380380+ "$type": "com.atproto.repo.applyWrites#update",
381381+ "collection": collection,
382382+ "rkey": rkey,
383383+ "value": value
384384+ ]
385385+ case .delete(let collection, let rkey):
386386+ return [
387387+ "$type": "com.atproto.repo.applyWrites#delete",
388388+ "collection": collection,
389389+ "rkey": rkey
390390+ ]
391391+ }
392392+ }
393393+}
394394+228395// MARK: - Post Record
229396230397/// A record for creating a post
···275442 ]
276443277444 if let facets, !facets.isEmpty {
278278- record["facets"] = facets.map { facet in
445445+ let encodedFacets: [[String: Any]] = facets.compactMap { facet in
279446 var dict: [String: Any] = [
280447 "index": [
281448 "byteStart": facet.index.byteStart,
282449 "byteEnd": facet.index.byteEnd
283450 ]
284451 ]
285285- dict["features"] = facet.features.map { feature -> [String: Any] in
452452+ let features: [[String: Any]] = facet.features.compactMap { feature in
286453 switch feature {
287454 case .link(let link):
288455 return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri]
289456 case .mention(let mention):
290290- return ["$type": "app.bsky.richtext.facet#mention", "did": mention.did ?? ""]
457457+ guard let did = mention.did else { return nil }
458458+ return ["$type": "app.bsky.richtext.facet#mention", "did": did]
291459 case .tag(let tag):
292460 return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag]
461461+ case .unknown:
462462+ return nil
293463 }
294464 }
465465+ guard !features.isEmpty else { return nil }
466466+ dict["features"] = features
295467 return dict
468468+ }
469469+ if !encodedFacets.isEmpty {
470470+ record["facets"] = encodedFacets
296471 }
297472 }
298473
+23-10
Sources/bskyKit/RichText/RichText.swift
···275275 /// A hashtag for discovery.
276276 case tag(RichTextTag)
277277278278+ /// Unknown facet feature type for forward compatibility.
279279+ case unknown(String)
280280+278281 enum CodingKeys: String, CodingKey {
279282 case type = "$type"
280283 case uri
···297300 let tag = try container.decode(String.self, forKey: .tag)
298301 self = .tag(RichTextTag(tag: tag))
299302 default:
300300- throw DecodingError.dataCorrupted(
301301- DecodingError.Context(
302302- codingPath: decoder.codingPath,
303303- debugDescription: "Unknown facet type: \(type)"
304304- )
305305- )
303303+ self = .unknown(type)
306304 }
307305 }
308306···313311 try container.encode("app.bsky.richtext.facet#link", forKey: .type)
314312 try container.encode(link.uri, forKey: .uri)
315313 case .mention(let mention):
314314+ guard let did = mention.did else {
315315+ throw EncodingError.invalidValue(
316316+ mention,
317317+ EncodingError.Context(
318318+ codingPath: encoder.codingPath,
319319+ debugDescription: "Mention facet requires a resolved DID."
320320+ )
321321+ )
322322+ }
316323 try container.encode("app.bsky.richtext.facet#mention", forKey: .type)
317317- try container.encode(mention.did ?? mention.handle, forKey: .did)
324324+ try container.encode(did, forKey: .did)
318325 case .tag(let tag):
319326 try container.encode("app.bsky.richtext.facet#tag", forKey: .type)
320327 try container.encode(tag.tag, forKey: .tag)
328328+ case .unknown(let type):
329329+ try container.encode(type, forKey: .type)
321330 }
322331 }
323332}
···386395 ]
387396 ]
388397389389- let features: [[String: Any]] = facet.features.map { feature in
398398+ let features: [[String: Any]] = facet.features.compactMap { feature in
390399 switch feature {
391400 case .link(let link):
392401 return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri]
···394403 if let did = mention.did {
395404 return ["$type": "app.bsky.richtext.facet#mention", "did": did]
396405 }
397397- return [:]
406406+ return nil
398407 case .tag(let tag):
399408 return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag]
409409+ case .unknown:
410410+ return nil
400411 }
401412 }
413413+414414+ guard !features.isEmpty else { return [:] }
402415403416 dict["features"] = features
404417 return dict
405405- }
418418+ }.filter { !$0.isEmpty }
406419 }
407420}