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
60 changes: 12 additions & 48 deletions Sources/CLI/client/AppleDocumentationClient+Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,61 +7,25 @@ extension DefaultAppleDocumentationClient {
metadata: [
"query": .string(query), "apple_docs.technology": .string(technology),
])
var slug = technology
var displayName = technology
var technologyURL = "https://developer.apple.com/documentation/\(technology.lowercased())"
let rootPage: TechnologyDocumentationPageDTO

do {
rootPage = try await fetchDocumentationPage(
path: "/documentation/\(technology.lowercased())"
)
} catch Error.httpStatus(404) {
logger.debug("Search root not found, resolving technology")
let resolved = try await resolveTechnology(named: technology)
guard let resolvedSlug = resolved.documentationSlug else {
logger.notice("Technology has no searchable documentation root")
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
slug = resolvedSlug
displayName = resolved.name
technologyURL = resolved.url
logger.debug("Retrying search with canonical technology", metadata: ["slug": .string(resolvedSlug)])
rootPage = try await fetchDocumentationPage(
path: "/documentation/\(resolvedSlug.lowercased())"
)
}

let matches = try await searchTypes(
query: query,
documentationSlug: slug,
displayName: displayName,
technologyURL: technologyURL,
rootPage: rootPage
)
let root = try await fetchDocumentationRoot(technology: technology)
let matches = try await searchTypes(query: query, root: root)
logger.info(
"Documentation search completed",
metadata: [
"apple_docs.technology": .string(displayName), "matches": .stringConvertible(matches.count),
"apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count),
])
return matches
}

private func searchTypes(
query: String,
documentationSlug: String,
displayName: String,
technologyURL: String,
rootPage: TechnologyDocumentationPageDTO
) async throws -> [DocumentationType] {
let rootPath = "/documentation/\(documentationSlug.lowercased())"
private func searchTypes(query: String, root: DocumentationRoot) async throws -> [DocumentationType] {
let rootPath = "/documentation/\(root.slug.lowercased())"
logger.debug("Traversing documentation collection groups", metadata: ["path": .string(rootPath)])
var typesByPath: [String: DocumentationType] = [:]
for type in documentationTypes(in: rootPage, technology: documentationSlug) {
for type in documentationTypes(in: root.page, technology: root.slug) {
typesByPath[type.path] = type
}
var visitedPaths = Set([rootPath])
var pendingPaths = collectionGroupPaths(in: rootPage, technology: documentationSlug).filter {
var pendingPaths = collectionGroupPaths(in: root.page, technology: root.slug).filter {
visitedPaths.insert($0).inserted
}

Expand All @@ -78,10 +42,10 @@ extension DefaultAppleDocumentationClient {
let pages = await fetchDocumentationPages(paths: batch)

for page in pages {
for type in documentationTypes(in: page, technology: documentationSlug) {
for type in documentationTypes(in: page, technology: root.slug) {
typesByPath[type.path] = type
}
for path in collectionGroupPaths(in: page, technology: documentationSlug)
for path in collectionGroupPaths(in: page, technology: root.slug)
where visitedPaths.insert(path).inserted {
pendingPaths.append(path)
}
Expand All @@ -99,13 +63,13 @@ extension DefaultAppleDocumentationClient {
logger.notice(
"No matching documentation types",
metadata: [
"query": .string(query), "apple_docs.technology": .string(displayName),
"query": .string(query), "apple_docs.technology": .string(root.name),
"candidates": .stringConvertible(typesByPath.count),
])
throw Error.typeSearchNoResults(
query: query,
technology: displayName,
technologyURL: technologyURL
technology: root.name,
technologyURL: root.url
)
}
return matches
Expand Down
73 changes: 51 additions & 22 deletions Sources/CLI/client/AppleDocumentationClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,28 +90,15 @@ struct DefaultAppleDocumentationClient<Dependencies: DefaultAppleDocumentationCl

func fetchTypes(technology: String) async throws -> [DocumentationType] {
logger.debug("Fetching documentation types", metadata: ["apple_docs.technology": .string(technology)])
do {
return try await fetchTypesDirect(technology: technology)
} catch Error.httpStatus(404) {
logger.debug("Type catalog not found, resolving technology")
let resolved = try await resolveTechnology(named: technology)
guard let slug = resolved.documentationSlug else {
logger.notice("Technology has no documentation root")
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
guard slug.caseInsensitiveCompare(technology) != .orderedSame else {
// Retrying the same case-insensitive path cannot produce a different result.
logger.notice("Documentation root unavailable, skipping identical retry")
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
logger.debug("Retrying type catalog with canonical technology", metadata: ["slug": .string(slug)])
do {
return try await fetchTypesDirect(technology: slug)
} catch Error.httpStatus(404) {
logger.notice("Canonical documentation root unavailable", metadata: ["slug": .string(slug)])
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
}
let root = try await fetchDocumentationRoot(technology: technology)
let types = sortTypes(documentationTypes(in: root.page, technology: root.slug))
logger.info(
"Fetched documentation types",
metadata: [
"path": .string("/documentation/\(root.slug.lowercased())"),
"count": .stringConvertible(types.count),
])
return types
}

func fetchTechnologies() async throws -> [Technology] {
Expand Down Expand Up @@ -210,6 +197,48 @@ struct DefaultAppleDocumentationClient<Dependencies: DefaultAppleDocumentationCl
}

extension DefaultAppleDocumentationClient {
struct DocumentationRoot: Sendable {
let page: TechnologyDocumentationPageDTO
let slug: String
let name: String
let url: String
}

func fetchDocumentationRoot(technology: String) async throws -> DocumentationRoot {
do {
return DocumentationRoot(
page: try await fetchDocumentationPage(path: "/documentation/\(technology.lowercased())"),
slug: technology,
name: technology,
url: "https://developer.apple.com/documentation/\(technology.lowercased())"
)
} catch Error.httpStatus(404) {
logger.debug("Documentation root not found, resolving technology")
let resolved = try await resolveTechnology(named: technology)
guard let slug = resolved.documentationSlug else {
logger.notice("Technology has no documentation root")
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
guard slug.caseInsensitiveCompare(technology) != .orderedSame else {
// Retrying the same case-insensitive path cannot produce a different result.
logger.notice("Documentation root unavailable, skipping identical retry")
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
logger.debug("Retrying documentation root with canonical technology", metadata: ["slug": .string(slug)])
do {
return DocumentationRoot(
page: try await fetchDocumentationPage(path: "/documentation/\(slug.lowercased())"),
slug: slug,
name: resolved.name,
url: resolved.url
)
} catch Error.httpStatus(404) {
logger.notice("Canonical documentation root unavailable", metadata: ["slug": .string(slug)])
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
}
}

private func fetchData(from url: URL) async throws -> Data {
let started = ContinuousClock.now
logger.debug("Requesting documentation data", metadata: ["path": .string(url.path)])
Expand Down
183 changes: 183 additions & 0 deletions Tests/CLITests/client/AppleDocumentationClientRootTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import Foundation
import Logging
import Testing

@testable import CLI

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

@Suite("Apple documentation root lookup")
struct AppleDocumentationClientRootTests {
@Test("maps a missing canonical root to unsupported technology guidance", arguments: [false, true])
func mapsMissingCanonicalRoot(search: Bool) async throws {
// -- Arrange --
let requestedURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/apple%20cryptokit.json")
)
let catalogURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json")
)
let canonicalURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json")
)
let transport = RootTestTransport(responses: [
requestedURL: .init(statusCode: 404, data: Data()),
catalogURL: .init(statusCode: 200, data: cryptoKitCatalogData),
canonicalURL: .init(statusCode: 404, data: Data()),
])
let client = DefaultAppleDocumentationClient(
logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport
)

// -- Act --
await #expect(
throws: DefaultAppleDocumentationClient<RootTestTransport>.Error.unsupportedTechnology(
name: "Apple CryptoKit", url: "https://developer.apple.com/documentation/cryptokit"
)
) {
if search {
_ = try await client.searchTypes(query: "AES", technology: "Apple CryptoKit")
} else {
_ = try await client.fetchTypes(technology: "Apple CryptoKit")
}
}

// -- Assert --
#expect(await transport.requestedURLs == [requestedURL, catalogURL, canonicalURL])
}

@Test("does not retry an identical case-insensitive root", arguments: [false, true])
func skipsIdenticalRootRetry(search: Bool) async throws {
// -- Arrange --
let rootURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json")
)
let catalogURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json")
)
let transport = RootTestTransport(responses: [
rootURL: .init(statusCode: 404, data: Data()),
catalogURL: .init(statusCode: 200, data: cryptoKitCatalogData),
])
let client = DefaultAppleDocumentationClient(
logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport
)

// -- Act --
await #expect(
throws: DefaultAppleDocumentationClient<RootTestTransport>.Error.unsupportedTechnology(
name: "Apple CryptoKit", url: "https://developer.apple.com/documentation/cryptokit"
)
) {
if search {
_ = try await client.searchTypes(query: "AES", technology: "CRYPTOKIT")
} else {
_ = try await client.fetchTypes(technology: "CRYPTOKIT")
}
}

// -- Assert --
#expect(await transport.requestedURLs == [rootURL, catalogURL])
}

@Test("returns symbols scoped to the canonical technology", arguments: [false, true])
func resolvesCanonicalRoot(search: Bool) async throws {
// -- Arrange --
let requestedURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/apple%20cryptokit.json")
)
let catalogURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json")
)
let canonicalURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json")
)
let rootData = Data(
"""
{"references":{"aes":{
"kind":"symbol", "role":"symbol", "title":"AES", "url":"/documentation/cryptokit/aes"
}}}
""".utf8
)
let transport = RootTestTransport(responses: [
requestedURL: .init(statusCode: 404, data: Data()),
catalogURL: .init(statusCode: 200, data: cryptoKitCatalogData),
canonicalURL: .init(statusCode: 200, data: rootData),
])
let client = DefaultAppleDocumentationClient(
logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport
)

// -- Act --
let types =
try await search
? client.searchTypes(query: "AES", technology: "Apple CryptoKit")
: client.fetchTypes(technology: "Apple CryptoKit")

// -- Assert --
#expect(types.map(\.name) == ["AES"])
#expect(types.map(\.path) == ["aes"])
#expect(await transport.requestedURLs == [requestedURL, catalogURL, canonicalURL])
}

@Test("propagates non-404 root failures without catalog lookup", arguments: [false, true], [429, 500])
func propagatesRootHTTPFailures(search: Bool, status: Int) async throws {
// -- Arrange --
let rootURL = try #require(
URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json")
)
let transport = RootTestTransport(responses: [rootURL: .init(statusCode: status, data: Data())])
let client = DefaultAppleDocumentationClient(
logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport
)

// -- Act --
await #expect(throws: DefaultAppleDocumentationClient<RootTestTransport>.Error.httpStatus(status)) {
if search {
_ = try await client.searchTypes(query: "AES", technology: "CryptoKit")
} else {
_ = try await client.fetchTypes(technology: "CryptoKit")
}
}

// -- Assert --
#expect(await transport.requestedURLs == [rootURL])
}
}

private let cryptoKitCatalogData = Data(
"""
{"sections":[{"groups":[{"technologies":[{
"title":"Apple CryptoKit",
"destination":{"identifier":"doc://com.apple.documentation/documentation/CryptoKit"}
}]}]}]}
""".utf8
)

private actor RootTestTransport: HTTPDataTransport {
struct Response: Sendable {
let statusCode: Int
let data: Data
}

let responses: [URL: Response]
private(set) var requestedURLs: [URL] = []

init(responses: [URL: Response]) {
self.responses = responses
}

func data(from url: URL) async throws -> (Data, URLResponse) {
requestedURLs.append(url)
let result = try #require(responses[url], "Unexpected request: \(url)")
let response = try #require(
HTTPURLResponse(
url: url, statusCode: result.statusCode, httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)
)
return (result.data, response)
}
}
Loading