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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- "platform=iOS Simulator,name=iPhone 16"
- "platform=tvOS Simulator,name=Apple TV"
- "platform=watchOS Simulator,name=Apple Watch Series 10 (42mm)"
- "platform=visionOS Simulator,name=Apple Vision Pro"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Github actions throws an error about finding two targets for Vision Pro, so this unblocks the macOS tests from executing.

- "platform=visionOS Simulator,name=Apple Vision Pro,OS=26.1"
steps:
- uses: actions/checkout@v4
- name: Test platform ${{ matrix.destination }}
Expand Down
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ let package = Package(
targets: ["ATResolve"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-async-dns-resolver.git", from: "0.4.0")
.package(url: "https://github.com/apple/swift-async-dns-resolver.git", from: "0.4.0"),
],
targets: [
.target(
name: "ATResolve",
dependencies: [
.product(name: "AsyncDNSResolver", package: "swift-async-dns-resolver"),
]),
.product(
name: "AsyncDNSResolver",
package: "swift-async-dns-resolver"
),
]
),
.testTarget(
name: "ATResolveTests",
dependencies: ["ATResolve"]
Expand Down
36 changes: 16 additions & 20 deletions Sources/ATResolve/ATResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,11 @@ public struct PLCDirectoryResolveDidResponse: Codable, Hashable, Sendable {
}
}

public protocol ResponseProviding {
func decodeJSON<T: Decodable>(at urlString: String, queryItems: [(String, String)]) async throws -> T
}

extension ResponseProviding {
public func decodeJSON<T: Decodable>(at urlString: String) async throws -> T {
try await decodeJSON(at: urlString, queryItems: [])
}
}

public struct ATResolver<Provider: ResponseProviding> {
public let provider: Provider
public struct ATResolver<Requester: ResponseProviding> {
public let requester: Requester

public init(provider: Provider) {
self.provider = provider
public init(requester: Requester) {
self.requester = requester
}

public func didForDomain(_ name: String) async throws -> String? {
Expand Down Expand Up @@ -75,15 +65,21 @@ public struct ATResolver<Provider: ResponseProviding> {
}

public func blueskyGetProfile(_ actor: String) async throws -> BlueskyProfile {
try await provider.decodeJSON(
at: "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile",
try await requester.decodeJSON(
host: "public.api.bsky.app",
path: "/xrpc/app.bsky.actor.getProfile",
headers: ["Accept": "application/json"],
queryItems: [("actor", actor)]
)
}

public func plcDirectoryQuery(_ did: String) async throws -> PLCDirectoryResolveDidResponse {
try await provider.decodeJSON(
at: "https://plc.directory/\(did)"
public func plcDirectoryQuery(
_ did: String
) async throws -> PLCDirectoryResolveDidResponse {
try await requester.decodeJSON(
host: "plc.directory",
path: "/\(did)",
headers: ["Accept": "application/json"]
)
}

Expand All @@ -99,7 +95,7 @@ public struct ATResolver<Provider: ResponseProviding> {
}
}

extension ATResolver: Sendable where Provider: Sendable {}
extension ATResolver: Sendable where Requester: Sendable {}

#if canImport(Foundation)
import Foundation
Expand Down
35 changes: 18 additions & 17 deletions Sources/ATResolve/Networking.swift
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
#if canImport(Foundation)
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

extension URLSession: ResponseProviding {
public func decodeJSON<T>(at urlString: String, queryItems: [(String, String)]) async throws -> T where T : Decodable {
guard var components = URLComponents(string: urlString) else {
throw ATResolverError.urlInvalid
}

components.queryItems = queryItems.map({ pair in
public func data(for request: Request) async throws -> Data {
var components = URLComponents()
components.scheme = "https"
components.host = request.host
components.path = request.path
components.queryItems = request.queryItems.map({ pair in
URLQueryItem(name: pair.0, value: pair.1)
})

guard let url = components.url else {
throw ATResolverError.urlInvalid
throw URLError(.badURL)
}

var request = URLRequest(url: url)

request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")

let (data, response) = try await URLSession.shared.data(for: request)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
for (key, value) in request.headers {
urlRequest.addValue(value, forHTTPHeaderField: key)
}
urlRequest.addValue("text/plain;charset=UTF-8", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: urlRequest)

guard
let httpResponse = response as? HTTPURLResponse,
Expand All @@ -33,7 +34,7 @@ extension URLSession: ResponseProviding {

throw ATResolverError.requestFailed
}

return try JSONDecoder().decode(T.self, from: data)
return data
}
}
#endif
56 changes: 56 additions & 0 deletions Sources/ATResolve/ResponseProviding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// ResponseProviding.swift
// ATResolve
//
// Created by Mark @ Germ on 12/1/25.
//

import Foundation

///Allows the client to choose between URLSession (Foundation) or AsyncHTTPClient,
///Or another networking library of their choice

//from Dave Delong
public struct HTTPMethod: Hashable, Sendable {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed very much like NIOHTTP1.HTTPRequest (an enum), but defined here to avoid adding library dependency on Swift NIO.

Source is
https://davedelong.com/blog/2020/06/27/http-in-swift-part-1/, where he argues the type is properly a struct not an enum.

public static let get = HTTPMethod(rawValue: "GET")
public static let post = HTTPMethod(rawValue: "POST")
public static let put = HTTPMethod(rawValue: "PUT")
public static let delete = HTTPMethod(rawValue: "DELETE")

public let rawValue: String
}

public struct Request {
public let host: String
public let path: String
public let method: HTTPMethod
public let headers: [String: String]
public let queryItems: [(String, String?)]
}

public protocol ResponseProviding {
func data(for: Request) async throws -> Data
}

extension ResponseProviding {
func decodeJSON<T: Decodable>(
host: String,
path: String,
method: HTTPMethod = .get,
headers: [String: String] = [:],
queryItems: [(String, String)] = []
) async throws -> T {
let result = try await data(
for: .init(
host: host,
path: path,
method: method,
headers: headers,
queryItems: queryItems
)
)

return try JSONDecoder().decode(T.self, from: result)
}

}
27 changes: 27 additions & 0 deletions Tests/ATResolveTests/ATResolveAsyncHTTPTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// ATResolveAsyncHTTPTests.swift
// ATResolve
//
// Created by Mark @ Germ on 12/1/25.
//

#if canImport(AsyncHTTPClient)
import ATResolve
import Foundation
import AsyncHTTPClient
import Testing

//Test API with AsyncHTTPClient
struct ATResolveAsyncHTTPTests {
@Test
func resolveHandle() async throws {
let resolver = ATResolver(requester: HTTPClient.shared)

let data = try await resolver.resolveHandle("massicotte.org")

#expect(data?.did == "did:plc:klsh7edzj3jmxucibyjqstb3")
#expect(data?.handle == "massicotte.org")
#expect(data?.serviceEndpoint == "https://milkcap.us-west.host.bsky.network")
}
}
#endif
18 changes: 9 additions & 9 deletions Tests/ATResolveTests/ATResolveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import FoundationNetworking
struct ATResolveTests {
@Test
func resolveHandle() async throws {
let resolver = ATResolver(provider: URLSession.shared)
let resolver = ATResolver(requester: URLSession.shared)

let data = try await resolver.resolveHandle("massicotte.org")

Expand All @@ -19,7 +19,7 @@ struct ATResolveTests {

@Test
func didForDomain() async throws {
let resolver = ATResolver(provider: URLSession.shared)
let resolver = ATResolver(requester: URLSession.shared)

let did = try await resolver.didForDomain("massicotte.org")

Expand All @@ -28,15 +28,15 @@ struct ATResolveTests {

@Test
func blueskyGetProfile() async throws {
let resolver = ATResolver(provider: URLSession.shared)
let resolver = ATResolver(requester: URLSession.shared)

let profile = try await resolver.blueskyGetProfile("massicotte.org")

#expect(profile.did == "did:plc:klsh7edzj3jmxucibyjqstb3")
}

@Test func bskySocialHandle() async throws {
let resolver = ATResolver(provider: URLSession.shared)
let resolver = ATResolver(requester: URLSession.shared)

let profile = try await resolver.resolveHandle("cjrdev.bsky.social")

Expand All @@ -49,15 +49,15 @@ struct ATResolveTests {
{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:klsh7edzj3jmxucibyjqstb3","alsoKnownAs":["at://massicotte.org"],"verificationMethod":[{"id":"did:plc:klsh7edzj3jmxucibyjqstb3#atproto","type":"Multikey","controller":"did:plc:klsh7edzj3jmxucibyjqstb3","publicKeyMultibase":"zQ3shP3NvazgSaEFpryzuyx8Q4MHho2KC2MNobAuQX3gdKAPW"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://milkcap.us-west.host.bsky.network"}]}
"""

func decodeJSON<T>(at urlString: String, queryItems: [(String, String)]) async throws -> T where T : Decodable {
try JSONDecoder().decode(T.self, from: Data(content.utf8))
func data(for: Request) async throws -> Data {
Data(content.utf8)
}
}

let resolver = ATResolver(provider: CustomProvider())

let resolver = ATResolver(requester: CustomProvider())
let response = try await resolver.plcDirectoryQuery("did:plc:klsh7edzj3jmxucibyjqstb3")

#expect(response.pds?.serviceEndpoint == "https://milkcap.us-west.host.bsky.network")
}
}
Loading