Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bsky-read-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@germ-network/atprotoclient": minor
---

Add three Bluesky read helpers on `Atproto.XRPC.BskyAppCallable`: `relationshipLookup(actor:others:)`, which reports which `app.bsky.graph.getRelationships` subjects the AppView returned a relationship for and which it didn't (unlike `getRelationships(actor:subjects:)`, which drops every not-found subject and loses which ones they were) - note that this isn't an account-existence check, since the AppView returns a relationship entry for any well-formed DID whether or not the account exists; and `bskyProfileIfExists(actor:)`, which maps `app.bsky.actor.getProfile`'s undeclared not-found shape (a 400 `InvalidRequest` whose message is "Profile not found") to `nil` rather than throwing. Also add `Atproto.BskyCDN.imageURL(_:did:blob:host:)` to build Bluesky CDN image URLs from a blob reference.

Also fix `Lexicon.App.Bsky.Graph.GetRelationships.Parameters.init` to accept exactly 30 `others`, matching the lexicon's `others.maxLength: 30` - it previously rejected exactly 30, and make `Lexicon.App.Bsky.Graph.GetRelationships.Errors` (`tooManyOthersInput`, `actorMismatch`) public - it's already thrown by public API.
89 changes: 89 additions & 0 deletions Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,33 @@ extension Atproto.XRPC.BskyAppCallable {
parameters: .init(actor: actor)
)
}

/// `getProfile` declares no dedicated not-found error - a missing actor
/// arrives as a 400 `InvalidRequest` whose message happens to be "Profile
/// not found". Maps exactly that shape to `nil`; every other error
/// (including other `InvalidRequest` messages, and a deactivated or
/// suspended account's `AccountDeactivated`/`AccountTakedown`) rethrows.
public func bskyProfileIfExists(
actor: LexiconString.AtIdentifier
) async throws -> Lexicon.App.Bsky.Actor.Defs.ProfileViewDetailed? {
do {
return try await bskyProfile(actor: actor)
} catch Atproto.XRPC.ParseError.xrpcError(
status: .badRequest,
error: let error
)
where error.error == "InvalidRequest"
&& error.message == "Profile not found"
{
return nil
}
}
}

extension Atproto.XRPC.BskyAppCallable {
/// Drops every not-found subject and loses which ones they were - use
/// ``relationshipLookup(actor:others:)`` to keep that information. Still
/// throws `GetRelationships.Errors.tooManyOthersInput` above 30 subjects.
public func getRelationships(
actor: Atproto.DID,
subjects: [Atproto.DID]
Expand All @@ -79,6 +103,71 @@ extension Atproto.XRPC.BskyAppCallable {
return results.relationships
.compactMap { $0.asRelationships }
}

/// `others` deduped (preserving order) and chunked into requests of at
/// most 30, the lexicon's `others.maxLength`, merging every chunk's
/// result. Only `.relationship`
/// entries for a requested DID count as found - an unrequested DID the
/// server threw in is dropped, and `.notFoundActor` is never used to
/// populate `found` (its `actor` can arrive in handle form, which can't
/// be mapped back to the DID that was actually requested). Whatever
/// wasn't found - whether the server said so explicitly (by DID or by
/// handle) or simply omitted it - lands in `notFound`. See
/// `GetRelationships.Lookup`'s doc for why this isn't an
/// account-existence check.
public func relationshipLookup(
actor: Atproto.DID,
others: [Atproto.DID]
) async throws -> Lexicon.App.Bsky.Graph.GetRelationships.Lookup {
let deduped = Self.dedupedPreservingOrder(others)
guard !deduped.isEmpty else {
return .init()
}
let requested = Set(deduped)

var found: [Atproto.DID: Lexicon.App.Bsky.Graph.Relationships] = [:]
for chunk in Self.chunked(
deduped,
intoSizeAtMost: Lexicon.App.Bsky.Graph.GetRelationships.Parameters.maxOthers
) {
let parameters = try Lexicon.App.Bsky.Graph.GetRelationships.Parameters(
actor: .did(actor),
others: chunk.map { .did($0) }
)
let output = try await call(
Lexicon.App.Bsky.Graph.GetRelationships.self,
parameters: parameters
)
guard output.actor == actor else {
throw Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch(
requested: actor,
returned: output.actor
)
}
for entry in output.relationships {
guard case .relationship(let relationship) = entry,
requested.contains(relationship.did)
else { continue }
found[relationship.did] = relationship
}
}
let notFound = deduped.filter { found[$0] == nil }
return .init(found: found, notFound: notFound)
}

private static func dedupedPreservingOrder(_ dids: [Atproto.DID]) -> [Atproto.DID] {
var seen = Set<Atproto.DID>()
return dids.filter { seen.insert($0).inserted }
}

private static func chunked(
_ dids: [Atproto.DID],
intoSizeAtMost size: Int
) -> [[Atproto.DID]] {
stride(from: 0, to: dids.count, by: size).map {
Array(dids[$0..<Swift.min($0 + size, dids.count)])
}
}
}

public enum BskyAppViewPublicSocialGraphs: Sendable {
Expand Down
111 changes: 111 additions & 0 deletions Sources/AtprotoClient/BskyCDN.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// BskyCDN.swift
// AtprotoClient
//

import AtprotoTypes
import Foundation

extension Atproto {
/// Builds Bluesky CDN URLs for a blob reference - a record's blob is a
/// content reference, not a URL.
public enum BskyCDN {
public static let defaultHost = URL(string: "https://cdn.bsky.app")!

public enum ImageKind: String, Sendable {
case avatar
case banner
}

/// - Parameter host: Must be a bare `http`/`https` origin (no path,
/// query, fragment, or userinfo) - throws otherwise. `@jpeg` is a
/// fixed CDN transcode target, not the blob's actual MIME type.
public static func imageURL(
_ kind: ImageKind,
did: Atproto.DID,
blob: Atproto.Primitive.Blob,
host: URL = defaultHost
) throws -> URL {
guard
let components = URLComponents(
url: host, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
scheme == "https" || scheme == "http",
let componentHost = components.host, !componentHost.isEmpty,
components.path.isEmpty || components.path == "/",
components.query == nil,
components.fragment == nil,
components.user == nil,
components.password == nil
else {
throw Errors.invalidHost
}
var built = components
// Normalize the scheme's case so "HTTPS://cdn.bsky.app" and
// "https://cdn.bsky.app/" both produce the same canonical URL as
// "https://cdn.bsky.app".
built.scheme = scheme
// `did.rawValue` is attacker-influenced (a DID's identifier is
// otherwise unvalidated) and only percent-encoding "/" out of it
// stops it from smuggling extra path segments (e.g.
// "did:plc:../../evil") into the CDN request - `URLComponents.path`
// leaves "/" alone since it's a legal path character, so this is
// set through `percentEncodedPath` instead, on an already-escaped
// string.
built.percentEncodedPath =
"/img/\(kind.rawValue)/plain/\(try Self.pathSegment(did.rawValue))/\(try Self.pathSegment(blob.ref.link.string))@jpeg"
guard let url = built.url else {
throw Errors.invalidHost
}
return url
}

// Built explicitly from RFC 3986 (unreserved + sub-delims + ":",
// excluding "/" and "@") rather than derived from
// `CharacterSet.urlPathAllowed` - Darwin's built-in set and a
// modified copy of it don't necessarily encode `:` the same way,
// which would make exact-URL tests diverge on Linux. Excluding "@"
// keeps the only literal "@" in the path the fixed "@jpeg" suffix.
private static let pathSegmentAllowed: CharacterSet = {
var allowed = CharacterSet()
allowed.insert(
charactersIn:
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
allowed.insert(charactersIn: "!$&'()*+,;=")
allowed.insert(charactersIn: ":")
return allowed
}()

private static func pathSegment(_ value: String) throws -> String {
guard
let encoded = value.addingPercentEncoding(
withAllowedCharacters: Self.pathSegmentAllowed)
else {
throw Errors.unencodablePathSegment
}
return encoded
}
}
}

extension Atproto.BskyCDN {
public enum Errors: Error, Equatable, Sendable {
/// `host` isn't a bare `http`/`https` origin (no path, query,
/// fragment, or userinfo).
case invalidHost
/// A DID or CID couldn't be percent-encoded into a path segment.
case unencodablePathSegment
}
}

extension Atproto.BskyCDN.Errors: LocalizedError {
public var errorDescription: String? {
switch self {
case .invalidHost:
"host must be a bare http(s) origin (no path, query, fragment, or userinfo)"
case .unencodablePathSegment:
"a DID or CID could not be percent-encoded into a CDN URL path segment"
}
}
}
5 changes: 4 additions & 1 deletion Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,8 @@ extension Lexicon.App.Bsky.Actor {
}

extension Lexicon.App.Bsky.Actor.GetProfile: Atproto.XRPC.ResponseParsing {
public static var badRequestErrors: Set<String> { defaultErrors }
/// Explicit about `InvalidRequest` rather than relying on it already
/// being in `defaultErrors` - `bskyProfileIfExists(actor:)` depends on
/// this error being parsed rather than falling through to `.unrecognized`.
public static var badRequestErrors: Set<String> { defaultErrors.union(["InvalidRequest"]) }
}
50 changes: 42 additions & 8 deletions Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ extension Lexicon.App.Bsky.Graph {
others: [LexiconString.AtIdentifier]?
) throws {
if let others {
guard others.count < Self.maxOthers else {
// The lexicon declares others.maxLength: 30, so exactly 30
// is valid - only 31+ is too many.
guard others.count <= Self.maxOthers else {
throw Errors.tooManyOthersInput
}
}
Expand Down Expand Up @@ -104,16 +106,48 @@ extension Lexicon.App.Bsky.Graph {
}
}

enum Errors: LocalizedError {
case tooManyOthersInput
/// Found/not-found subjects from ``Atproto.XRPC.BskyAppCallable/relationshipLookup(actor:others:)``,
/// which - unlike ``Atproto.XRPC.BskyAppCallable/getRelationships(actor:subjects:)`` -
/// keeps track of which requested subjects weren't found.
///
/// `found`/`notFound` isn't an account-existence check: the AppView
/// returns a `#relationship` entry for any well-formed DID, whether or
/// not the account exists, so a nonexistent `did:plc` still lands in
/// `found`. `notFound` only covers what the server explicitly reported
/// via `notFoundActor` (in practice, for a handle that doesn't
/// resolve) or omitted outright. Callers that need to know whether an
/// account actually exists should use
/// ``Atproto.XRPC.BskyAppCallable/bskyProfileIfExists(actor:)``.
public struct Lookup: Sendable {
public var found: [Atproto.DID: Relationships]
public var notFound: [Atproto.DID]

var errorDescription: String? {
switch self {
case .tooManyOthersInput:
"Too many others input"
}
public init(
found: [Atproto.DID: Relationships] = [:],
notFound: [Atproto.DID] = []
) {
self.found = found
self.notFound = notFound
}
}

public enum Errors: Error, Equatable, Sendable {
case tooManyOthersInput
/// A chunked request's response named a different `actor` than the
/// one requested.
case actorMismatch(requested: Atproto.DID, returned: Atproto.DID)
}
}
}

extension Lexicon.App.Bsky.Graph.GetRelationships.Errors: LocalizedError {
public var errorDescription: String? {
switch self {
case .tooManyOthersInput:
"Too many others input"
case .actorMismatch(let requested, let returned):
"getRelationships returned actor \(returned.rawValue), requested \(requested.rawValue)"
}
}
}

Expand Down
Loading
Loading