From 372e0eaa7ae06effcb3806d62928a6f2958ac51f Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 15 Sep 2026 15:17:14 +0200 Subject: [PATCH] ref(client): Share technology root lookup for list and search --- .../AppleDocumentationClient+Search.swift | 60 ++---- .../CLI/client/AppleDocumentationClient.swift | 73 ++++--- .../AppleDocumentationClientRootTests.swift | 183 ++++++++++++++++++ 3 files changed, 246 insertions(+), 70 deletions(-) create mode 100644 Tests/CLITests/client/AppleDocumentationClientRootTests.swift diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/client/AppleDocumentationClient+Search.swift index 525dc73..70e0a9a 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Search.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Search.swift @@ -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 } @@ -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) } @@ -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 diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index d7729bc..acdf83c 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -90,28 +90,15 @@ struct DefaultAppleDocumentationClient [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] { @@ -210,6 +197,48 @@ struct DefaultAppleDocumentationClient 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)]) diff --git a/Tests/CLITests/client/AppleDocumentationClientRootTests.swift b/Tests/CLITests/client/AppleDocumentationClientRootTests.swift new file mode 100644 index 0000000..8a784bb --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientRootTests.swift @@ -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.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.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.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) + } +}