From 7092855a870a4476a5cd08bb74bcce4910591de2 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 14 Oct 2026 11:54:53 +0200 Subject: [PATCH] ref(documentation): Separate repository from HTTP client --- .../AppleDocumentationClient+Error.swift | 76 +---- .../CLI/client/AppleDocumentationClient.swift | 289 +--------------- .../DocumentationTypeCatalogClient.swift | 22 -- .../DocumentationTypeSearchClient.swift | 27 -- .../CLI/client/TechnologyCatalogClient.swift | 22 -- .../TechnologiesListCommand.swift | 4 +- .../TechnologiesListCommandRunner.swift | 4 +- Sources/CLI/cmd/types/TypesListCommand.swift | 4 +- .../cmd/types/TypesListCommandRunner.swift | 4 +- .../CLI/cmd/types/TypesSearchCommand.swift | 4 +- .../cmd/types/TypesSearchCommandRunner.swift | 4 +- Sources/CLI/cmd/types/TypesViewCommand.swift | 4 +- .../cmd/types/TypesViewCommandRunner.swift | 12 +- Sources/CLI/main/Dependencies.swift | 8 +- .../main/DocumentationErrorPresentation.swift | 48 +++ .../DefaultTypeDocumentationRenderer.swift | 3 +- .../renderer/TypeDocumentationRenderer.swift | 2 +- .../AppleDocumentationRepository+Error.swift | 21 ++ ...AppleDocumentationRepository+Search.swift} | 2 +- .../AppleDocumentationRepository.swift | 316 ++++++++++++++++++ .../DocumentationTypeCatalogRepository.swift | 24 ++ .../DocumentationTypeSearchRepository.swift | 29 ++ .../TechnologyCatalogRepository.swift | 24 ++ .../TechnologiesListCommandRunnerTests.swift | 4 +- .../TechnologiesListCommandTests.swift | 6 +- .../types/TypesListCommandRunnerTests.swift | 2 +- .../cmd/types/TypesListCommandTests.swift | 6 +- .../types/TypesSearchCommandRunnerTests.swift | 2 +- .../cmd/types/TypesSearchCommandTests.swift | 6 +- .../TypesViewCommandExecutionTests.swift | 19 +- .../types/TypesViewCommandRunnerTests.swift | 16 +- ...efaultTypeDocumentationRendererTests.swift | 19 +- ...leDocumentationRepositoryErrorTests.swift} | 4 +- ...DocumentationRepositoryLoggingTests.swift} | 22 +- ...eDocumentationRepositoryLookupTests.swift} | 14 +- ...pleDocumentationRepositoryPageTests.swift} | 6 +- ...pleDocumentationRepositoryRootTests.swift} | 16 +- ...eDocumentationRepositorySearchTests.swift} | 18 +- ...umentationRepositoryTechnologyTests.swift} | 4 +- .../AppleDocumentationRepositoryTests.swift | 49 +++ ...ocumentationRepositoryTypeListTests.swift} | 8 +- .../DocumentationRepositoryFixture.swift | 13 + .../telemetry/SentryConfigurationTests.swift | 2 +- 43 files changed, 664 insertions(+), 525 deletions(-) delete mode 100644 Sources/CLI/client/DocumentationTypeCatalogClient.swift delete mode 100644 Sources/CLI/client/DocumentationTypeSearchClient.swift delete mode 100644 Sources/CLI/client/TechnologyCatalogClient.swift create mode 100644 Sources/CLI/main/DocumentationErrorPresentation.swift create mode 100644 Sources/CLI/repository/AppleDocumentationRepository+Error.swift rename Sources/CLI/{client/AppleDocumentationClient+Search.swift => repository/AppleDocumentationRepository+Search.swift} (99%) create mode 100644 Sources/CLI/repository/AppleDocumentationRepository.swift create mode 100644 Sources/CLI/repository/DocumentationTypeCatalogRepository.swift create mode 100644 Sources/CLI/repository/DocumentationTypeSearchRepository.swift create mode 100644 Sources/CLI/repository/TechnologyCatalogRepository.swift rename Tests/CLITests/{client/AppleDocumentationClientErrorTests.swift => repository/AppleDocumentationRepositoryErrorTests.swift} (96%) rename Tests/CLITests/{client/AppleDocumentationClientLoggingTests.swift => repository/AppleDocumentationRepositoryLoggingTests.swift} (93%) rename Tests/CLITests/{client/AppleDocumentationClientTests.swift => repository/AppleDocumentationRepositoryLookupTests.swift} (92%) rename Tests/CLITests/{client/AppleDocumentationClientPageTests.swift => repository/AppleDocumentationRepositoryPageTests.swift} (94%) rename Tests/CLITests/{client/AppleDocumentationClientRootTests.swift => repository/AppleDocumentationRepositoryRootTests.swift} (91%) rename Tests/CLITests/{client/AppleDocumentationClientSearchTests.swift => repository/AppleDocumentationRepositorySearchTests.swift} (95%) rename Tests/CLITests/{client/AppleDocumentationClientTechnologyTests.swift => repository/AppleDocumentationRepositoryTechnologyTests.swift} (96%) create mode 100644 Tests/CLITests/repository/AppleDocumentationRepositoryTests.swift rename Tests/CLITests/{client/AppleDocumentationClientTypeListTests.swift => repository/AppleDocumentationRepositoryTypeListTests.swift} (96%) create mode 100644 Tests/CLITests/repository/DocumentationRepositoryFixture.swift diff --git a/Sources/CLI/client/AppleDocumentationClient+Error.swift b/Sources/CLI/client/AppleDocumentationClient+Error.swift index 7d08911..a211484 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Error.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Error.swift @@ -1,73 +1,13 @@ import Foundation -extension DefaultAppleDocumentationClient { - enum Error: Swift.Error, Equatable, LocalizedError, ExpectedCommandError { - case httpStatus(Int) - case invalidResponse - case technologyNotFound(String) - case typeNotFound( - name: String, - technology: String, - suggestion: DocumentationType?, - technologyURL: String - ) - case unsupportedTechnology(name: String, url: String) - - var isExpected: Bool { - switch self { - case .technologyNotFound, .typeNotFound, .unsupportedTechnology: - return true - case .httpStatus, .invalidResponse: - return false - } +enum AppleDocumentationClientError: Error, Equatable, LocalizedError { + case httpStatus(Int) + case invalidResponse + + var errorDescription: String? { + switch self { + case .httpStatus(let status): return "Apple documentation returned HTTP status \(status)." + case .invalidResponse: return "Apple documentation returned an invalid response." } - - var errorDescription: String? { - switch self { - case .httpStatus(let statusCode): - return "Apple documentation returned HTTP status \(statusCode)." - case .invalidResponse: - return "Apple documentation returned an invalid response." - case .technologyNotFound(let technology): - return """ - Apple documentation technology '\(technology)' was not found. - - Browse available technologies: - apple-docs technologies list - """ - case .typeNotFound(let name, let technology, let suggestion, let technologyURL): - var sections = ["No Apple documentation found for '\(name)' in \(technology)."] - if let suggestion { - sections.append( - """ - Did you mean: - \(suggestion.name) - \(suggestion.url) - """ - ) - } - sections.append( - """ - Browse available types: - apple-docs types list --technology "\(technology)" - \(technologyURL) - """ - ) - return sections.joined(separator: "\n\n") - case .unsupportedTechnology(let name, let url): - return """ - Type retrieval is unavailable for \(name). - - Continue in the technology documentation: - \(url) - """ - } - } - } - - struct ResolvedTechnology { - let name: String - let documentationSlug: String? - let url: String } } diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index b8ded84..25bd9b1 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -7,21 +7,15 @@ import Logging #if DEBUG protocol AppleDocumentationClient: Sendable { - func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument + func fetchDocument(at destination: DocumentationDestination) async throws -> TypeDocumentationDocument + func fetchTechnologyCatalog() async throws -> TechnologyCatalogPageDTO } extension DefaultAppleDocumentationClient: AppleDocumentationClient {} - - protocol AppleDocumentationClientProvider { - associatedtype Client: AppleDocumentationClient - var documentationClient: Client { get } - } - - extension Dependencies: AppleDocumentationClientProvider {} #else typealias AppleDocumentationClient = DefaultAppleDocumentationClient #endif -struct DefaultAppleDocumentationClient: Sendable { +final class DefaultAppleDocumentationClient: Sendable { private static var defaultBaseURL: URL { guard let url = URL(string: "https://developer.apple.com/tutorials/data/") else { preconditionFailure("Invalid base URL for documentation client") @@ -44,226 +38,27 @@ struct DefaultAppleDocumentationClient TypeDocumentationDocument { - let metadata: Logger.Metadata = [ - "apple_docs.type": .string(name), "apple_docs.technology": .string(technology), - ] - logger.debug("Fetching type documentation", metadata: metadata) - do { - let document = try await fetchDocument(at: typeDestination(name: name, technology: technology)) - logger.info("Fetched type documentation", metadata: metadata) - return document - } catch Error.httpStatus(404) { - logger.debug("Type path not found, resolving technology", metadata: metadata) - let resolved = try await resolveTechnology(named: technology) - guard let slug = resolved.documentationSlug else { - logger.notice("Technology has no documentation root", metadata: metadata) - throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) - } - - // Display names do not always match DocC path components, such as Apple CryptoKit. - if slug.caseInsensitiveCompare(technology) != .orderedSame { - logger.debug("Retrying type with canonical technology", metadata: ["slug": .string(slug)]) - do { - let document = try await fetchDocument(at: typeDestination(name: name, technology: slug)) - logger.info("Fetched type documentation", metadata: metadata) - return document - } catch Error.httpStatus(404) { - logger.debug("Canonical type path not found, looking for suggestions", metadata: metadata) - } - } - - let types = try await fetchTypesDirect(technology: slug) - let normalizedName = normalizedSymbolName(name) - let suggestion = types.first { - normalizedSymbolName($0.name) == normalizedName - } - logger.notice("Type not found", metadata: ["suggestion_found": .stringConvertible(suggestion != nil)]) - throw Error.typeNotFound( - name: name, - technology: resolved.name, - suggestion: suggestion, - technologyURL: resolved.url - ) - } - } - - func fetchTypes(technology: String) async throws -> [DocumentationType] { - logger.debug("Fetching documentation types", metadata: ["apple_docs.technology": .string(technology)]) - 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] { - logger.debug("Fetching technology catalog") - let url = baseURL.appending(component: "documentation") - .appending(component: "technologies") - .appendingPathExtension("json") - let data = try await fetchData(from: url) - let page: TechnologyCatalogPageDTO - do { - page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) - } catch { - logger.error("Failed to decode technology catalog", metadata: ["path": .string(url.path)]) - throw error - } - let technologies = page.sections.flatMap(\.groups).flatMap(\.technologies).map { - Technology(name: $0.title, identifier: $0.destination.identifier) - } - logger.info("Fetched technology catalog", metadata: ["count": .stringConvertible(technologies.count)]) - return technologies - } - - private func fetchTypesDirect(technology: String) async throws -> [DocumentationType] { - let path = "/documentation/\(technology.lowercased())" - let page = try await fetchDocumentationPage(path: path) - let types = sortTypes(documentationTypes(in: page, technology: technology)) - logger.info( - "Fetched documentation types", metadata: ["path": .string(path), "count": .stringConvertible(types.count)]) - return types - } - func fetchDocument(at destination: DocumentationDestination) async throws -> TypeDocumentationDocument { - TypeDocumentationDocument( - data: try await fetchDocumentationData(path: destination.path), destination: destination - ) - } - - func fetchRootDocument(technology: String) async throws -> TypeDocumentationDocument { - try await fetchDocumentationRoot(technology: technology).document - } - - private func fetchDocumentationData(path: String) async throws -> Data { - logger.debug("Fetching documentation page", metadata: ["path": .string(path)]) var url = baseURL - for component in path.split(separator: "/") { + for component in destination.path.split(separator: "/") { url.append(component: component) } url.appendPathExtension("json") - return try await fetchData(from: url) + return TypeDocumentationDocument(data: try await fetchData(from: url), destination: destination) } - func fetchDocumentationPage(path: String) async throws -> TechnologyDocumentationPageDTO { - try decodeDiscoveryPage(try await fetchDocumentationData(path: path), path: path) - } - - private func decodeDiscoveryPage(_ data: Data, path: String) throws -> TechnologyDocumentationPageDTO { + func fetchTechnologyCatalog() async throws -> TechnologyCatalogPageDTO { + let url = baseURL.appending(component: "documentation") + .appending(component: "technologies").appendingPathExtension("json") + let data = try await fetchData(from: url) do { - let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) - logger.trace( - "Decoded documentation page", metadata: ["references": .stringConvertible(page.references.count)]) - return page + return try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) } catch { - logger.error("Failed to decode documentation page", metadata: ["path": .string(path)]) + logger.error("Failed to decode technology catalog", metadata: ["path": .string(url.path)]) throw error } } - func documentationTypes( - in page: TechnologyDocumentationPageDTO, - technology: String - ) -> [DocumentationType] { - let pathPrefix = "/documentation/\(technology.lowercased())/" - - // Pages may reference articles and neighboring frameworks. Role and path filtering keeps - // search results scoped to APIs in the requested technology. - let types: [DocumentationType] = page.references.values.compactMap { reference in - guard - reference.kind == "symbol", - reference.role == "symbol", - let name = reference.title, - let referencePath = reference.url, - referencePath.lowercased().hasPrefix(pathPrefix) - else { - return nil - } - - let path = String(referencePath.dropFirst(pathPrefix.count)) - let kind = reference.fragments?.first { $0.kind == "keyword" }?.text ?? "symbol" - return DocumentationType( - name: name, - kind: kind, - path: path, - url: "https://developer.apple.com\(referencePath)" - ) - } - logger.trace( - "Filtered documentation symbols", - metadata: [ - "references": .stringConvertible(page.references.count), "count": .stringConvertible(types.count), - ]) - return types - } - - func sortTypes(_ types: S) -> [DocumentationType] where S.Element == DocumentationType { - let sorted = types.sorted { - let comparison = $0.name.compare($1.name, options: .caseInsensitive) - return comparison == .orderedSame ? $0.path < $1.path : comparison == .orderedAscending - } - logger.trace("Sorted documentation types", metadata: ["count": .stringConvertible(sorted.count)]) - return sorted - } - -} - -extension DefaultAppleDocumentationClient { - struct DocumentationRoot: Sendable { - let page: TechnologyDocumentationPageDTO - let document: TypeDocumentationDocument - let slug: String - let name: String - let url: String - } - - func fetchDocumentationRoot(technology: String) async throws -> DocumentationRoot { - do { - return try await loadRoot( - 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 try await loadRoot(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 loadRoot(slug: String, name: String, url: String) async throws -> DocumentationRoot { - let destination = DocumentationDestination( - technology: slug.lowercased(), path: "/documentation/\(slug.lowercased())" - ) - let document = try await fetchDocument(at: destination) - return DocumentationRoot( - page: try decodeDiscoveryPage(document.data, path: destination.path), document: document, - slug: slug, name: name, url: url - ) - } - private func fetchData(from url: URL) async throws -> Data { let started = ContinuousClock.now logger.debug("Requesting documentation data", metadata: ["path": .string(url.path)]) @@ -289,7 +84,7 @@ extension DefaultAppleDocumentationClient { } guard let httpResponse = response as? HTTPURLResponse else { logger.error("Invalid documentation response", metadata: ["path": .string(url.path)]) - throw Error.invalidResponse + throw AppleDocumentationClientError.invalidResponse } let metadata: Logger.Metadata = [ "path": .string(url.path), "status": .stringConvertible(httpResponse.statusCode), @@ -302,67 +97,9 @@ extension DefaultAppleDocumentationClient { ? .debug : (httpResponse.statusCode >= 500 ? .error : .warning) logger.log(level: level, "Documentation request rejected", metadata: metadata) - throw Error.httpStatus(httpResponse.statusCode) + throw AppleDocumentationClientError.httpStatus(httpResponse.statusCode) } return data } - private func typeDestination(name: String, technology: String) -> DocumentationDestination { - // Slash-separated DocC paths may contain dots in disambiguation suffixes. - let pathName = name.contains("/") ? name : name.replacingOccurrences(of: ".", with: "/") - let components = pathName.split(separator: "/") - let path = "/documentation/\(technology.lowercased())/" + components.joined(separator: "/").lowercased() - return DocumentationDestination(technology: technology.lowercased(), path: path) - } - - func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology { - logger.debug("Resolving technology", metadata: ["apple_docs.technology": .string(requestedName)]) - let technologies = try await fetchTechnologies() - guard - let technology = technologies.first(where: { - $0.name.caseInsensitiveCompare(requestedName) == .orderedSame - || documentationSlug(from: $0.identifier)?.caseInsensitiveCompare(requestedName) - == .orderedSame - }) - else { - logger.notice("Technology not found", metadata: ["apple_docs.technology": .string(requestedName)]) - throw Error.technologyNotFound(requestedName) - } - - logger.debug("Resolved technology", metadata: ["apple_docs.technology": .string(technology.name)]) - return ResolvedTechnology( - name: technology.name, - documentationSlug: documentationSlug(from: technology.identifier), - url: publicURL(from: technology.identifier) - ) - } - - private func documentationSlug(from identifier: String) -> String? { - let marker = "/documentation/" - guard let range = identifier.range(of: marker) else { - logger.trace("Technology identifier has no documentation slug") - return nil - } - let remainder = identifier[range.upperBound...] - guard !remainder.contains("/") else { - logger.trace("Ignoring nested documentation slug") - return nil - } - logger.trace("Extracted documentation slug", metadata: ["slug": .string(String(remainder))]) - return String(remainder) - } - - private func publicURL(from identifier: String) -> String { - guard identifier.hasPrefix("doc://"), let pathStart = identifier.dropFirst(6).firstIndex(of: "/") else { - logger.trace("Keeping external technology URL") - return identifier - } - logger.trace("Converted DocC identifier to public URL") - return "https://developer.apple.com\(identifier[pathStart...].lowercased())" - } - - private func normalizedSymbolName(_ name: String) -> String { - logger.trace("Normalizing symbol name for suggestions") - return name.lowercased().filter { $0.isLetter || $0.isNumber } - } } diff --git a/Sources/CLI/client/DocumentationTypeCatalogClient.swift b/Sources/CLI/client/DocumentationTypeCatalogClient.swift deleted file mode 100644 index fe9f70e..0000000 --- a/Sources/CLI/client/DocumentationTypeCatalogClient.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if DEBUG - protocol DocumentationTypeCatalogClient: Sendable { - func fetchTypes(technology: String) async throws -> [DocumentationType] - } - - extension DefaultAppleDocumentationClient: DocumentationTypeCatalogClient {} - - protocol DocumentationTypeCatalogClientProvider { - associatedtype Client: DocumentationTypeCatalogClient - var documentationClient: Client { get } - } - - extension Dependencies: DocumentationTypeCatalogClientProvider {} -#else - typealias DocumentationTypeCatalogClient = DefaultAppleDocumentationClient -#endif diff --git a/Sources/CLI/client/DocumentationTypeSearchClient.swift b/Sources/CLI/client/DocumentationTypeSearchClient.swift deleted file mode 100644 index e4cfe82..0000000 --- a/Sources/CLI/client/DocumentationTypeSearchClient.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -struct DocumentationSearchResult: Equatable, Sendable { - let types: [DocumentationType] - let unavailableCollectionPaths: [String] -} - -#if DEBUG - protocol DocumentationTypeSearchClient: Sendable { - func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult - } - - extension DefaultAppleDocumentationClient: DocumentationTypeSearchClient {} - - protocol DocumentationTypeSearchClientProvider { - associatedtype Client: DocumentationTypeSearchClient - var documentationClient: Client { get } - } - - extension Dependencies: DocumentationTypeSearchClientProvider {} -#else - typealias DocumentationTypeSearchClient = DefaultAppleDocumentationClient -#endif diff --git a/Sources/CLI/client/TechnologyCatalogClient.swift b/Sources/CLI/client/TechnologyCatalogClient.swift deleted file mode 100644 index 00b4983..0000000 --- a/Sources/CLI/client/TechnologyCatalogClient.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -#if DEBUG - protocol TechnologyCatalogClient: Sendable { - func fetchTechnologies() async throws -> [Technology] - } - - extension DefaultAppleDocumentationClient: TechnologyCatalogClient {} - - protocol TechnologyCatalogClientProvider { - associatedtype Client: TechnologyCatalogClient - var documentationClient: Client { get } - } - - extension Dependencies: TechnologyCatalogClientProvider {} -#else - typealias TechnologyCatalogClient = DefaultAppleDocumentationClient -#endif diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index 744bb71..c0ace84 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -3,7 +3,7 @@ import ArgumentParser struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( - TelemetryProvider & TerminalCapabilitiesProvider & TechnologyCatalogClientProvider + TelemetryProvider & TerminalCapabilitiesProvider & TechnologyCatalogRepositoryProvider & TechnologyListRendererProvider & CommandOutputWriterProvider ) #else @@ -26,7 +26,7 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { let context = TelemetryCommandContext.technologiesList(json: output.json) deps.telemetry.startCommand(context) let runner = TechnologiesListCommandRunner( - client: deps.documentationClient, + client: deps.documentationRepository, renderer: deps.technologyListRenderer(output: output) ) let result = try await runner.run(mode: deps.terminalCapabilities.mode(for: output)) diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift index 01af697..7e60a02 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift @@ -6,11 +6,11 @@ struct TechnologiesListCommandRunner: Sendable { let technologyCount: Int } - private let client: TechnologyCatalogClient + private let client: TechnologyCatalogRepository private let renderer: TechnologyListRenderer init( - client: TechnologyCatalogClient, + client: TechnologyCatalogRepository, renderer: TechnologyListRenderer ) { self.client = client diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index b0f38f7..138fc5b 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -3,7 +3,7 @@ import ArgumentParser struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( - TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeCatalogClientProvider + TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeCatalogRepositoryProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider ) #else @@ -29,7 +29,7 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { let context = TelemetryCommandContext.typesList(technology: technology, json: output.json) deps.telemetry.startCommand(context) let runner = TypesListCommandRunner( - client: deps.documentationClient, + client: deps.documentationRepository, renderer: deps.documentationTypeListRenderer(output: output, technology: technology) ) let result = try await runner.run(technology: technology, mode: deps.terminalCapabilities.mode(for: output)) diff --git a/Sources/CLI/cmd/types/TypesListCommandRunner.swift b/Sources/CLI/cmd/types/TypesListCommandRunner.swift index 8f4ba8f..c9ea1bd 100644 --- a/Sources/CLI/cmd/types/TypesListCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesListCommandRunner.swift @@ -4,11 +4,11 @@ struct TypesListCommandRunner: Sendable { let typeCount: Int } - private let client: DocumentationTypeCatalogClient + private let client: DocumentationTypeCatalogRepository private let renderer: DocumentationTypeListRenderer init( - client: DocumentationTypeCatalogClient, + client: DocumentationTypeCatalogRepository, renderer: DocumentationTypeListRenderer ) { self.client = client diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 73e98c0..4c21b23 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -4,7 +4,7 @@ import Foundation struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( - TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchClientProvider + TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchRepositoryProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider ) #else @@ -34,7 +34,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { let context = TelemetryCommandContext.typesSearch(technology: technology, json: output.json) deps.telemetry.startCommand(context) let runner = TypesSearchCommandRunner( - client: deps.documentationClient, + client: deps.documentationRepository, renderer: deps.documentationTypeListRenderer(output: output, technology: technology) ) let result = try await runner.run( diff --git a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift index e47013b..2a87699 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift @@ -5,11 +5,11 @@ struct TypesSearchCommandRunner: Sendable { let unavailableCollectionCount: Int } - private let client: DocumentationTypeSearchClient + private let client: DocumentationTypeSearchRepository private let renderer: DocumentationTypeListRenderer init( - client: DocumentationTypeSearchClient, + client: DocumentationTypeSearchRepository, renderer: DocumentationTypeListRenderer ) { self.client = client diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index daeacd7..1f7d121 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -3,7 +3,7 @@ import ArgumentParser struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( - TelemetryProvider & TerminalCapabilitiesProvider & AppleDocumentationClientProvider + TelemetryProvider & TerminalCapabilitiesProvider & AppleDocumentationRepositoryProvider & TypeDocumentationRendererProvider & CommandOutputWriterProvider ) #else @@ -32,7 +32,7 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { let context = TelemetryCommandContext.typesView(name: name, technology: technology, json: output.json) deps.telemetry.startCommand(context) let runner = TypesViewCommandRunner( - client: deps.documentationClient, + repository: deps.documentationRepository, renderer: deps.documentationRenderer(output: output) ) let result = try await runner.run( diff --git a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift index c5c76f5..b6851eb 100644 --- a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift @@ -4,25 +4,25 @@ struct TypesViewCommandRunner: Sendable { let responseByteCount: Int } - private let client: AppleDocumentationClient + private let repository: AppleDocumentationRepository private let renderer: TypeDocumentationRenderer init( - client: AppleDocumentationClient, + repository: AppleDocumentationRepository, renderer: TypeDocumentationRenderer ) { - self.client = client + self.repository = repository self.renderer = renderer } func run(name: String, technology: String, mode: OutputMode) async throws -> Result { - let document = try await client.fetchType( + let document = try await repository.fetchPage( named: name, technology: technology ) return Result( - output: try renderer.render(document), - responseByteCount: document.data.count + output: try renderer.render(document.page), + responseByteCount: document.responseByteCount ) } } diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index fc159a9..9742fb8 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -18,7 +18,7 @@ struct Dependencies: Sendable { ) let httpCache: URLCache? let httpDataTransport: URLSession - let documentationClient: DefaultAppleDocumentationClient + let documentationRepository: DefaultAppleDocumentationRepository> init() { if let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { @@ -39,8 +39,10 @@ struct Dependencies: Sendable { configuration.urlCache = httpCache } httpDataTransport = URLSession(configuration: configuration) - documentationClient = DefaultAppleDocumentationClient( - logger: Logger(label: "com.techprimate.apple-docs.client"), dependencies: httpDataTransport) + documentationRepository = DefaultAppleDocumentationRepository( + logger: Logger(label: "com.techprimate.apple-docs.repository"), + dependencies: DefaultAppleDocumentationClient( + logger: Logger(label: "com.techprimate.apple-docs.client"), dependencies: httpDataTransport)) } var documentationCache: URLCache? { diff --git a/Sources/CLI/main/DocumentationErrorPresentation.swift b/Sources/CLI/main/DocumentationErrorPresentation.swift new file mode 100644 index 0000000..ffd1b18 --- /dev/null +++ b/Sources/CLI/main/DocumentationErrorPresentation.swift @@ -0,0 +1,48 @@ +import Foundation + +extension AppleDocumentationRepositoryError: LocalizedError, ExpectedCommandError { + var isExpected: Bool { + switch self { + case .technologyNotFound, .typeNotFound, .unsupportedTechnology: + return true + } + } + + var errorDescription: String? { + switch self { + case .technologyNotFound(let technology): + return """ + Apple documentation technology '\(technology)' was not found. + + Browse available technologies: + apple-docs technologies list + """ + case .typeNotFound(let name, let technology, let suggestion, let technologyURL): + var sections = ["No Apple documentation found for '\(name)' in \(technology)."] + if let suggestion { + sections.append( + """ + Did you mean: + \(suggestion.name) + \(suggestion.url) + """ + ) + } + sections.append( + """ + Browse available types: + apple-docs types list --technology "\(technology)" + \(technologyURL) + """ + ) + return sections.joined(separator: "\n\n") + case .unsupportedTechnology(let name, let url): + return """ + Type retrieval is unavailable for \(name). + + Continue in the technology documentation: + \(url) + """ + } + } +} diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index d625c48..d5a1508 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -4,8 +4,7 @@ struct DefaultTypeDocumentationRenderer: Sendable { let output: Output var audience: OutputAudience = .human - func render(_ document: TypeDocumentationDocument) throws -> String { - let page = try DocumentationPageDecoder().decode(document.data, destination: document.destination) + func render(_ page: DocumentationPage) throws -> String { let presentation = DocumentationPresenter().page(page, audience: audience) switch output { case .json: diff --git a/Sources/CLI/renderer/TypeDocumentationRenderer.swift b/Sources/CLI/renderer/TypeDocumentationRenderer.swift index 57948c7..0b8b562 100644 --- a/Sources/CLI/renderer/TypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TypeDocumentationRenderer.swift @@ -1,6 +1,6 @@ #if DEBUG protocol TypeDocumentationRenderer: Sendable { - func render(_ document: TypeDocumentationDocument) throws -> String + func render(_ document: DocumentationPage) throws -> String } extension DefaultTypeDocumentationRenderer: TypeDocumentationRenderer {} diff --git a/Sources/CLI/repository/AppleDocumentationRepository+Error.swift b/Sources/CLI/repository/AppleDocumentationRepository+Error.swift new file mode 100644 index 0000000..1563248 --- /dev/null +++ b/Sources/CLI/repository/AppleDocumentationRepository+Error.swift @@ -0,0 +1,21 @@ +import Foundation + +enum AppleDocumentationRepositoryError: Swift.Error, Equatable { + case technologyNotFound(String) + case typeNotFound( + name: String, + technology: String, + suggestion: DocumentationType?, + technologyURL: String + ) + case unsupportedTechnology(name: String, url: String) + +} + +extension DefaultAppleDocumentationRepository { + struct ResolvedTechnology { + let name: String + let documentationSlug: String? + let url: String + } +} diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/repository/AppleDocumentationRepository+Search.swift similarity index 99% rename from Sources/CLI/client/AppleDocumentationClient+Search.swift rename to Sources/CLI/repository/AppleDocumentationRepository+Search.swift index 80e86db..2fa0170 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Search.swift +++ b/Sources/CLI/repository/AppleDocumentationRepository+Search.swift @@ -1,6 +1,6 @@ import Foundation -extension DefaultAppleDocumentationClient { +extension DefaultAppleDocumentationRepository { func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { let root = try await fetchDocumentationRoot(technology: technology) return try await searchDocumentation(query: query, root: root) diff --git a/Sources/CLI/repository/AppleDocumentationRepository.swift b/Sources/CLI/repository/AppleDocumentationRepository.swift new file mode 100644 index 0000000..aa7dfef --- /dev/null +++ b/Sources/CLI/repository/AppleDocumentationRepository.swift @@ -0,0 +1,316 @@ +import Foundation +import Logging + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +struct LoadedDocumentationPage: Sendable { + let page: DocumentationPage + let responseByteCount: Int +} + +#if DEBUG + protocol AppleDocumentationRepository: Sendable { + func fetchPage(named name: String, technology: String) async throws -> LoadedDocumentationPage + } + extension DefaultAppleDocumentationRepository: AppleDocumentationRepository {} + + protocol AppleDocumentationRepositoryProvider { + associatedtype Repository: AppleDocumentationRepository + var documentationRepository: Repository { get } + } + extension Dependencies: AppleDocumentationRepositoryProvider {} + typealias DefaultAppleDocumentationRepositoryDependencies = AppleDocumentationClient +#else + typealias AppleDocumentationRepository = DefaultAppleDocumentationRepository< + DefaultAppleDocumentationClient + > + typealias DefaultAppleDocumentationRepositoryDependencies = DefaultAppleDocumentationClient +#endif + +struct DefaultAppleDocumentationRepository: Sendable { + let logger: Logger + private let dependencies: Dependencies + + init(logger: Logger, dependencies: Dependencies) { + self.logger = logger + self.dependencies = dependencies + } + + func fetchPage(named name: String, technology: String) async throws -> LoadedDocumentationPage { + try decodePage(try await fetchType(named: name, technology: technology)) + } + + func fetchPage(at destination: DocumentationDestination) async throws -> LoadedDocumentationPage { + try decodePage(try await fetchDocument(at: destination)) + } + + private func decodePage(_ document: TypeDocumentationDocument) throws -> LoadedDocumentationPage { + LoadedDocumentationPage( + page: try DocumentationPageDecoder().decode(document.data, destination: document.destination), + responseByteCount: document.data.count) + } + + func fetchType( + named name: String, + technology: String + ) async throws -> TypeDocumentationDocument { + let metadata: Logger.Metadata = [ + "apple_docs.type": .string(name), "apple_docs.technology": .string(technology), + ] + logger.debug("Fetching type documentation", metadata: metadata) + do { + let document = try await fetchDocument(at: typeDestination(name: name, technology: technology)) + logger.info("Fetched type documentation", metadata: metadata) + return document + } catch AppleDocumentationClientError.httpStatus(404) { + logger.debug("Type path not found, resolving technology", metadata: metadata) + let resolved = try await resolveTechnology(named: technology) + guard let slug = resolved.documentationSlug else { + logger.notice("Technology has no documentation root", metadata: metadata) + throw AppleDocumentationRepositoryError.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + + // Display names do not always match DocC path components, such as Apple CryptoKit. + if slug.caseInsensitiveCompare(technology) != .orderedSame { + logger.debug("Retrying type with canonical technology", metadata: ["slug": .string(slug)]) + do { + let document = try await fetchDocument(at: typeDestination(name: name, technology: slug)) + logger.info("Fetched type documentation", metadata: metadata) + return document + } catch AppleDocumentationClientError.httpStatus(404) { + logger.debug("Canonical type path not found, looking for suggestions", metadata: metadata) + } + } + + let types = try await fetchTypesDirect(technology: slug) + let normalizedName = normalizedSymbolName(name) + let suggestion = types.first { + normalizedSymbolName($0.name) == normalizedName + } + logger.notice("Type not found", metadata: ["suggestion_found": .stringConvertible(suggestion != nil)]) + throw AppleDocumentationRepositoryError.typeNotFound( + name: name, + technology: resolved.name, + suggestion: suggestion, + technologyURL: resolved.url + ) + } + } + + func fetchTypes(technology: String) async throws -> [DocumentationType] { + logger.debug("Fetching documentation types", metadata: ["apple_docs.technology": .string(technology)]) + 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] { + logger.debug("Fetching technology catalog") + let page = try await dependencies.fetchTechnologyCatalog() + let technologies = page.sections.flatMap(\.groups).flatMap(\.technologies).map { + Technology(name: $0.title, identifier: $0.destination.identifier) + } + logger.info("Fetched technology catalog", metadata: ["count": .stringConvertible(technologies.count)]) + return technologies + } + + private func fetchTypesDirect(technology: String) async throws -> [DocumentationType] { + let path = "/documentation/\(technology.lowercased())" + let page = try await fetchDocumentationPage(path: path) + let types = sortTypes(documentationTypes(in: page, technology: technology)) + logger.info( + "Fetched documentation types", metadata: ["path": .string(path), "count": .stringConvertible(types.count)]) + return types + } + + func fetchDocument(at destination: DocumentationDestination) async throws -> TypeDocumentationDocument { + try await dependencies.fetchDocument(at: destination) + } + + func fetchRootDocument(technology: String) async throws -> TypeDocumentationDocument { + try await fetchDocumentationRoot(technology: technology).document + } + + func fetchDocumentationPage(path: String) async throws -> TechnologyDocumentationPageDTO { + try decodeDiscoveryPage( + try await dependencies.fetchDocument( + at: DocumentationDestination( + technology: String(path.split(separator: "/").dropFirst().first ?? ""), path: path) + ).data, + path: path) + } + + private func decodeDiscoveryPage(_ data: Data, path: String) throws -> TechnologyDocumentationPageDTO { + do { + let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) + logger.trace( + "Decoded documentation page", metadata: ["references": .stringConvertible(page.references.count)]) + return page + } catch { + logger.error("Failed to decode documentation page", metadata: ["path": .string(path)]) + throw error + } + } + + func documentationTypes( + in page: TechnologyDocumentationPageDTO, + technology: String + ) -> [DocumentationType] { + let pathPrefix = "/documentation/\(technology.lowercased())/" + + // Pages may reference articles and neighboring frameworks. Role and path filtering keeps + // search results scoped to APIs in the requested technology. + let types: [DocumentationType] = page.references.values.compactMap { reference in + guard + reference.kind == "symbol", + reference.role == "symbol", + let name = reference.title, + let referencePath = reference.url, + referencePath.lowercased().hasPrefix(pathPrefix) + else { + return nil + } + + let path = String(referencePath.dropFirst(pathPrefix.count)) + let kind = reference.fragments?.first { $0.kind == "keyword" }?.text ?? "symbol" + return DocumentationType( + name: name, + kind: kind, + path: path, + url: "https://developer.apple.com\(referencePath)" + ) + } + logger.trace( + "Filtered documentation symbols", + metadata: [ + "references": .stringConvertible(page.references.count), "count": .stringConvertible(types.count), + ]) + return types + } + + func sortTypes(_ types: S) -> [DocumentationType] where S.Element == DocumentationType { + let sorted = types.sorted { + let comparison = $0.name.compare($1.name, options: .caseInsensitive) + return comparison == .orderedSame ? $0.path < $1.path : comparison == .orderedAscending + } + logger.trace("Sorted documentation types", metadata: ["count": .stringConvertible(sorted.count)]) + return sorted + } + +} + +extension DefaultAppleDocumentationRepository { + struct DocumentationRoot: Sendable { + let page: TechnologyDocumentationPageDTO + let document: TypeDocumentationDocument + let slug: String + let name: String + let url: String + } + + func fetchDocumentationRoot(technology: String) async throws -> DocumentationRoot { + do { + return try await loadRoot( + slug: technology, name: technology, + url: "https://developer.apple.com/documentation/\(technology.lowercased())" + ) + } catch AppleDocumentationClientError.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 AppleDocumentationRepositoryError.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 AppleDocumentationRepositoryError.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + logger.debug("Retrying documentation root with canonical technology", metadata: ["slug": .string(slug)]) + do { + return try await loadRoot(slug: slug, name: resolved.name, url: resolved.url) + } catch AppleDocumentationClientError.httpStatus(404) { + logger.notice("Canonical documentation root unavailable", metadata: ["slug": .string(slug)]) + throw AppleDocumentationRepositoryError.unsupportedTechnology(name: resolved.name, url: resolved.url) + } + } + } + + private func loadRoot(slug: String, name: String, url: String) async throws -> DocumentationRoot { + let destination = DocumentationDestination( + technology: slug.lowercased(), path: "/documentation/\(slug.lowercased())" + ) + let document = try await fetchDocument(at: destination) + return DocumentationRoot( + page: try decodeDiscoveryPage(document.data, path: destination.path), document: document, + slug: slug, name: name, url: url + ) + } + + private func typeDestination(name: String, technology: String) -> DocumentationDestination { + // Slash-separated DocC paths may contain dots in disambiguation suffixes. + let pathName = name.contains("/") ? name : name.replacingOccurrences(of: ".", with: "/") + let components = pathName.split(separator: "/") + let path = "/documentation/\(technology.lowercased())/" + components.joined(separator: "/").lowercased() + return DocumentationDestination(technology: technology.lowercased(), path: path) + } + + func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology { + logger.debug("Resolving technology", metadata: ["apple_docs.technology": .string(requestedName)]) + let technologies = try await fetchTechnologies() + guard + let technology = technologies.first(where: { + $0.name.caseInsensitiveCompare(requestedName) == .orderedSame + || documentationSlug(from: $0.identifier)?.caseInsensitiveCompare(requestedName) + == .orderedSame + }) + else { + logger.notice("Technology not found", metadata: ["apple_docs.technology": .string(requestedName)]) + throw AppleDocumentationRepositoryError.technologyNotFound(requestedName) + } + + logger.debug("Resolved technology", metadata: ["apple_docs.technology": .string(technology.name)]) + return ResolvedTechnology( + name: technology.name, + documentationSlug: documentationSlug(from: technology.identifier), + url: publicURL(from: technology.identifier) + ) + } + + private func documentationSlug(from identifier: String) -> String? { + let marker = "/documentation/" + guard let range = identifier.range(of: marker) else { + logger.trace("Technology identifier has no documentation slug") + return nil + } + let remainder = identifier[range.upperBound...] + guard !remainder.contains("/") else { + logger.trace("Ignoring nested documentation slug") + return nil + } + logger.trace("Extracted documentation slug", metadata: ["slug": .string(String(remainder))]) + return String(remainder) + } + + private func publicURL(from identifier: String) -> String { + guard identifier.hasPrefix("doc://"), let pathStart = identifier.dropFirst(6).firstIndex(of: "/") else { + logger.trace("Keeping external technology URL") + return identifier + } + logger.trace("Converted DocC identifier to public URL") + return "https://developer.apple.com\(identifier[pathStart...].lowercased())" + } + + private func normalizedSymbolName(_ name: String) -> String { + logger.trace("Normalizing symbol name for suggestions") + return name.lowercased().filter { $0.isLetter || $0.isNumber } + } +} diff --git a/Sources/CLI/repository/DocumentationTypeCatalogRepository.swift b/Sources/CLI/repository/DocumentationTypeCatalogRepository.swift new file mode 100644 index 0000000..e291c34 --- /dev/null +++ b/Sources/CLI/repository/DocumentationTypeCatalogRepository.swift @@ -0,0 +1,24 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if DEBUG + protocol DocumentationTypeCatalogRepository: Sendable { + func fetchTypes(technology: String) async throws -> [DocumentationType] + } + + extension DefaultAppleDocumentationRepository: DocumentationTypeCatalogRepository {} + + protocol DocumentationTypeCatalogRepositoryProvider { + associatedtype Repository: DocumentationTypeCatalogRepository + var documentationRepository: Repository { get } + } + + extension Dependencies: DocumentationTypeCatalogRepositoryProvider {} +#else + typealias DocumentationTypeCatalogRepository = DefaultAppleDocumentationRepository< + DefaultAppleDocumentationClient + > +#endif diff --git a/Sources/CLI/repository/DocumentationTypeSearchRepository.swift b/Sources/CLI/repository/DocumentationTypeSearchRepository.swift new file mode 100644 index 0000000..90e5145 --- /dev/null +++ b/Sources/CLI/repository/DocumentationTypeSearchRepository.swift @@ -0,0 +1,29 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +struct DocumentationSearchResult: Equatable, Sendable { + let types: [DocumentationType] + let unavailableCollectionPaths: [String] +} + +#if DEBUG + protocol DocumentationTypeSearchRepository: Sendable { + func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult + } + + extension DefaultAppleDocumentationRepository: DocumentationTypeSearchRepository {} + + protocol DocumentationTypeSearchRepositoryProvider { + associatedtype Repository: DocumentationTypeSearchRepository + var documentationRepository: Repository { get } + } + + extension Dependencies: DocumentationTypeSearchRepositoryProvider {} +#else + typealias DocumentationTypeSearchRepository = DefaultAppleDocumentationRepository< + DefaultAppleDocumentationClient + > +#endif diff --git a/Sources/CLI/repository/TechnologyCatalogRepository.swift b/Sources/CLI/repository/TechnologyCatalogRepository.swift new file mode 100644 index 0000000..51135c8 --- /dev/null +++ b/Sources/CLI/repository/TechnologyCatalogRepository.swift @@ -0,0 +1,24 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +#if DEBUG + protocol TechnologyCatalogRepository: Sendable { + func fetchTechnologies() async throws -> [Technology] + } + + extension DefaultAppleDocumentationRepository: TechnologyCatalogRepository {} + + protocol TechnologyCatalogRepositoryProvider { + associatedtype Repository: TechnologyCatalogRepository + var documentationRepository: Repository { get } + } + + extension Dependencies: TechnologyCatalogRepositoryProvider {} +#else + typealias TechnologyCatalogRepository = DefaultAppleDocumentationRepository< + DefaultAppleDocumentationClient + > +#endif diff --git a/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift b/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift index 18620c2..33b92e9 100644 --- a/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift @@ -8,7 +8,7 @@ struct TechnologiesListCommandRunnerTests { @Test("sorts technologies case-insensitively before rendering") func sortsTechnologies() async throws { // -- Arrange -- - let client = UnsortedTechnologyCatalogClient( + let client = UnsortedTechnologyCatalogRepository( technologies: [ Technology(name: "swiftUI", identifier: "swiftui"), Technology(name: "MetricKit", identifier: "metrickit"), @@ -33,7 +33,7 @@ struct TechnologiesListCommandRunnerTests { } } -private struct UnsortedTechnologyCatalogClient: TechnologyCatalogClient { +private struct UnsortedTechnologyCatalogRepository: TechnologyCatalogRepository { let technologies: [Technology] func fetchTechnologies() async throws -> [Technology] { diff --git a/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift b/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift index 865ce18..6f16ab4 100644 --- a/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift +++ b/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift @@ -24,19 +24,19 @@ struct TechnologiesListCommandTests { } private typealias TechnologiesProviders = TelemetryProvider & TerminalCapabilitiesProvider - & TechnologyCatalogClientProvider & TechnologyListRendererProvider & CommandOutputWriterProvider + & TechnologyCatalogRepositoryProvider & TechnologyListRendererProvider & CommandOutputWriterProvider private struct TechnologiesDependencies: TechnologiesProviders { let telemetry = RecordingCommandTelemetry() let terminalCapabilities = DefaultTerminalCapabilities() - let documentationClient = TechnologiesTestClient() + let documentationRepository = TechnologiesTestClient() let output = RecordingCommandOutputWriter() var commandOutputWriter: RecordingCommandOutputWriter { output } func technologyListRenderer(output: OutputOptions) -> TechnologiesTestRenderer { TechnologiesTestRenderer() } } -private struct TechnologiesTestClient: TechnologyCatalogClient { +private struct TechnologiesTestClient: TechnologyCatalogRepository { func fetchTechnologies() async throws -> [Technology] { [Technology(name: "Swift", identifier: "doc://com.apple.documentation/documentation/Swift")] } diff --git a/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift index ca5bc3a..76c7f6e 100644 --- a/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesListCommandRunnerTests.swift @@ -29,7 +29,7 @@ struct TypesListCommandRunnerTests { } } -private struct RequestedTypesClient: DocumentationTypeCatalogClient { +private struct RequestedTypesClient: DocumentationTypeCatalogRepository { let types: [DocumentationType] func fetchTypes(technology: String) async throws -> [DocumentationType] { diff --git a/Tests/CLITests/cmd/types/TypesListCommandTests.swift b/Tests/CLITests/cmd/types/TypesListCommandTests.swift index 864b540..f05cd63 100644 --- a/Tests/CLITests/cmd/types/TypesListCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesListCommandTests.swift @@ -24,12 +24,12 @@ struct TypesListCommandTests { } private typealias TestProviders = TelemetryProvider & TerminalCapabilitiesProvider - & DocumentationTypeCatalogClientProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider + & DocumentationTypeCatalogRepositoryProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider private struct TestDeps: TestProviders { let telemetry = TypesListTelemetryRecorder() let terminalCapabilities = TypesListTestTerminal() - let documentationClient = TypesListTestClient() + let documentationRepository = TypesListTestClient() let output = RecordingCommandOutputWriter() var commandOutputWriter: RecordingCommandOutputWriter { output } @@ -44,7 +44,7 @@ private struct TypesListTestTerminal: TerminalCapabilities { var stdinIsTTY: Bool { false } } -private struct TypesListTestClient: DocumentationTypeCatalogClient { +private struct TypesListTestClient: DocumentationTypeCatalogRepository { func fetchTypes(technology: String) async throws -> [DocumentationType] { #expect(technology == "SwiftData") return [ diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift index 488f085..2094935 100644 --- a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift @@ -48,7 +48,7 @@ struct TypesSearchCommandRunnerTests { } } -private struct RequestedTypeSearchClient: DocumentationTypeSearchClient { +private struct RequestedTypeSearchClient: DocumentationTypeSearchRepository { let types: [DocumentationType] func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift index b682d93..47a9312 100644 --- a/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift @@ -25,12 +25,12 @@ struct TypesSearchCommandTests { } private typealias SearchProviders = TelemetryProvider & TerminalCapabilitiesProvider - & DocumentationTypeSearchClientProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider + & DocumentationTypeSearchRepositoryProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider private struct SearchDependencies: SearchProviders { let telemetry = RecordingCommandTelemetry() let terminalCapabilities = DefaultTerminalCapabilities() - let documentationClient = SearchTestClient() + let documentationRepository = SearchTestClient() let output = RecordingCommandOutputWriter() var commandOutputWriter: RecordingCommandOutputWriter { output } @@ -39,7 +39,7 @@ private struct SearchDependencies: SearchProviders { } } -private struct SearchTestClient: DocumentationTypeSearchClient { +private struct SearchTestClient: DocumentationTypeSearchRepository { func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { #expect(query == "Button") #expect(technology == "SwiftUI") diff --git a/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift index fdbf010..74d69e6 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift @@ -25,29 +25,30 @@ struct TypesViewCommandExecutionTests { } private typealias ViewProviders = TelemetryProvider & TerminalCapabilitiesProvider - & AppleDocumentationClientProvider & TypeDocumentationRendererProvider & CommandOutputWriterProvider + & AppleDocumentationRepositoryProvider & TypeDocumentationRendererProvider & CommandOutputWriterProvider private struct ViewDependencies: ViewProviders { let telemetry = RecordingCommandTelemetry() let terminalCapabilities = DefaultTerminalCapabilities() - let documentationClient = ViewTestClient() + let documentationRepository = ViewTestClient() let output = RecordingCommandOutputWriter() var commandOutputWriter: RecordingCommandOutputWriter { output } func documentationRenderer(output: OutputOptions) -> ViewTestRenderer { ViewTestRenderer() } } -private struct ViewTestClient: AppleDocumentationClient { - func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument { +private struct ViewTestClient: AppleDocumentationRepository { + func fetchPage(named name: String, technology: String) async throws -> LoadedDocumentationPage { #expect(name == "String") #expect(technology == "Swift") - return TypeDocumentationDocument( - data: Data("page".utf8), - destination: DocumentationDestination(technology: "swift", path: "/documentation/swift/string") - ) + return LoadedDocumentationPage( + page: DocumentationPage( + destination: .init(technology: "swift", path: "/documentation/swift/string"), + title: "String", kind: "struct"), + responseByteCount: 4) } } private struct ViewTestRenderer: TypeDocumentationRenderer { - func render(_ document: TypeDocumentationDocument) -> String { "rendered page" } + func render(_ document: DocumentationPage) -> String { "rendered page" } } diff --git a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift index dd809e4..4fa39a1 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift @@ -32,10 +32,9 @@ struct TypesViewCommandRunnerTests { document: document ) let renderer = RequestedTypeRenderer( - expectedData: data, output: "rendered documentation" ) - let runner = TypesViewCommandRunner(client: client, renderer: renderer) + let runner = TypesViewCommandRunner(repository: client, renderer: renderer) // -- Act -- let result = try await runner.run( @@ -50,25 +49,26 @@ struct TypesViewCommandRunnerTests { } } -private struct RequestedTypeClient: AppleDocumentationClient { +private struct RequestedTypeClient: AppleDocumentationRepository { let expectedName: String let expectedTechnology: String let document: TypeDocumentationDocument - func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument { + func fetchPage(named name: String, technology: String) async throws -> LoadedDocumentationPage { guard name == expectedName, technology == expectedTechnology else { throw TestDocumentationClientError.unexpectedRequest } - return document + return LoadedDocumentationPage( + page: try DocumentationPageDecoder().decode(document.data, destination: document.destination), + responseByteCount: document.data.count) } } private struct RequestedTypeRenderer: TypeDocumentationRenderer { - let expectedData: Data let output: String - func render(_ document: TypeDocumentationDocument) -> String { - guard document.data == expectedData else { + func render(_ document: DocumentationPage) -> String { + guard document.title == "MXHangDiagnostic" else { return "unexpected document" } return output diff --git a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift index b142ebb..685ed1b 100644 --- a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift @@ -14,7 +14,8 @@ struct DefaultTypeDocumentationRendererTests { let renderer = DefaultTypeDocumentationRenderer(output: .text) // -- Act -- - let output = try renderer.render(document) + let output = try renderer.render( + try DocumentationPageDecoder().decode(document.data, destination: document.destination)) // -- Assert -- #expect(output.hasPrefix("SwiftUI\n")) @@ -42,7 +43,8 @@ struct DefaultTypeDocumentationRendererTests { let renderer = DefaultTypeDocumentationRenderer(output: .text) // -- Act -- - let output = try renderer.render(document) + let output = try renderer.render( + try DocumentationPageDecoder().decode(document.data, destination: document.destination)) // -- Assert -- #expect( @@ -81,7 +83,8 @@ struct DefaultTypeDocumentationRendererTests { let renderer = DefaultTypeDocumentationRenderer(output: .text) // -- Act -- - let output = try renderer.render(document) + let output = try renderer.render( + try DocumentationPageDecoder().decode(document.data, destination: document.destination)) // -- Assert -- #expect( @@ -129,7 +132,8 @@ struct DefaultTypeDocumentationRendererTests { let renderer = DefaultTypeDocumentationRenderer(output: .text) // -- Act -- - let output = try renderer.render(document) + let output = try renderer.render( + try DocumentationPageDecoder().decode(document.data, destination: document.destination)) // -- Assert -- #expect(output.hasPrefix("Distributing binary frameworks as Swift packages\n")) @@ -148,7 +152,8 @@ struct DefaultTypeDocumentationRendererTests { let renderer = DefaultTypeDocumentationRenderer(output: .json, audience: audience) // -- Act -- - let output = try renderer.render(document) + let output = try renderer.render( + try DocumentationPageDecoder().decode(document.data, destination: document.destination)) let value = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) // -- Assert -- @@ -166,7 +171,9 @@ struct DefaultTypeDocumentationRendererTests { let renderer = DefaultTypeDocumentationRenderer(output: .json) // -- Act -- - let render = { try renderer.render(document) } + let render = { + try renderer.render(try DocumentationPageDecoder().decode(document.data, destination: document.destination)) + } // -- Assert -- #expect(throws: (any Error).self) { try render() } diff --git a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryErrorTests.swift similarity index 96% rename from Tests/CLITests/client/AppleDocumentationClientErrorTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryErrorTests.swift index 9ef6b0e..6bee477 100644 --- a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryErrorTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CLI @Suite("Apple documentation client errors") -struct AppleDocumentationClientErrorTests { +struct AppleDocumentationRepositoryErrorTests { @Test("maps a missing type to discovery guidance") func mapsMissingTypeToDiscoveryGuidance() async throws { // -- Arrange -- @@ -18,7 +18,7 @@ struct AppleDocumentationClientErrorTests { let rootURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [ diff --git a/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryLoggingTests.swift similarity index 93% rename from Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryLoggingTests.swift index bf79e87..4b705f6 100644 --- a/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryLoggingTests.swift @@ -9,7 +9,7 @@ import Testing #endif @Suite("Apple documentation client logging") -struct AppleDocumentationClientLoggingTests { +struct AppleDocumentationRepositoryLoggingTests { @available(macOS 15, *) @Test("logs successful requests without exposing response bodies or URL credentials") func logsSuccessfulRequest() async throws { @@ -17,7 +17,7 @@ struct AppleDocumentationClientLoggingTests { let recorder = ClientLogRecorder() let url = try #require(URL(string: "https://user:secret@example.com/data/")) let data = Data("private response body".utf8) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ url.appending(path: "documentation/swift/string.json"): .http(data: data) @@ -53,7 +53,7 @@ struct AppleDocumentationClientLoggingTests { func logsHTTPFailure(status: Int) async throws { // -- Arrange -- let recorder = ClientLogRecorder() - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL("swift"): .http(statusCode: status, data: Data()) @@ -62,7 +62,7 @@ struct AppleDocumentationClientLoggingTests { let expectedLevel: Logger.Level = status == 404 ? .debug : (status >= 500 ? .error : .warning) // -- Act -- - await #expect(throws: DefaultAppleDocumentationClient.Error.httpStatus(status)) { + await #expect(throws: AppleDocumentationClientError.httpStatus(status)) { try await client.fetchDocumentationPage(path: "/documentation/swift") } @@ -80,7 +80,7 @@ struct AppleDocumentationClientLoggingTests { func logsTransportFailure(code: URLError.Code) async throws { // -- Arrange -- let recorder = ClientLogRecorder() - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL("swift/string"): .failure(URLError(code)) @@ -105,7 +105,7 @@ struct AppleDocumentationClientLoggingTests { let recorder = ClientLogRecorder() let url = try #require(URL(string: "https://example.com")) let response = URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL("swift/string"): .response(data: Data(), response: response) @@ -113,7 +113,7 @@ struct AppleDocumentationClientLoggingTests { ) // -- Act -- - await #expect(throws: DefaultAppleDocumentationClient.Error.invalidResponse) { + await #expect(throws: AppleDocumentationClientError.invalidResponse) { try await client.fetchType(named: "String", technology: "Swift") } @@ -129,7 +129,7 @@ struct AppleDocumentationClientLoggingTests { func logsDecodingFailure(catalog: Bool) async throws { // -- Arrange -- let recorder = ClientLogRecorder() - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL(catalog ? "technologies" : "swift"): @@ -158,7 +158,7 @@ struct AppleDocumentationClientLoggingTests { func logsEmptySearch() async throws { // -- Arrange -- let recorder = ClientLogRecorder() - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL("swift"): .http(data: Data("{\"references\":{}}".utf8)) @@ -188,7 +188,7 @@ struct AppleDocumentationClientLoggingTests { "controls":{"role":"collectionGroup","url":"/documentation/swiftui/controls"} }} """ - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL("swiftui"): .http(data: Data(root.utf8)), @@ -223,7 +223,7 @@ struct AppleDocumentationClientLoggingTests { "destination":{"identifier":"doc://com.apple.documentation/documentation/CryptoKit"} }]}]}]} """ - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: recorder.logger(), dependencies: HTTPTestTransport(responses: [ try documentationURL("apple%20cryptokit/aes"): .http(statusCode: 404, data: Data()), diff --git a/Tests/CLITests/client/AppleDocumentationClientTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryLookupTests.swift similarity index 92% rename from Tests/CLITests/client/AppleDocumentationClientTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryLookupTests.swift index 3c276d1..b0e0c04 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryLookupTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CLI @Suite("Apple documentation client") -struct AppleDocumentationClientTests { +struct AppleDocumentationRepositoryLookupTests { @Test("requests and decodes a type documentation page") func fetchesTypeDocumentation() async throws { // -- Arrange -- @@ -29,7 +29,7 @@ struct AppleDocumentationClientTests { """.utf8 ) let transport = HTTPTestTransport(responses: [expectedURL: .http(data: data)]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) @@ -49,7 +49,7 @@ struct AppleDocumentationClientTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/string.json") ) let data = Data("{\"newUpstreamShape\":true}".utf8) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport(responses: [expectedURL: .http(data: data)]) ) @@ -73,7 +73,7 @@ struct AppleDocumentationClientTests { ) ) let transport = HTTPTestTransport(responses: [expectedURL: .http(data: Data("{}".utf8))]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) @@ -95,7 +95,7 @@ struct AppleDocumentationClientTests { ) let data = Data("{}".utf8) let transport = HTTPTestTransport(responses: [expectedURL: .http(data: data)]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) @@ -114,7 +114,7 @@ struct AppleDocumentationClientTests { let expectedURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/metrickit/missingtype.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [expectedURL: .http(statusCode: 500, data: Data("Not Found".utf8))] @@ -125,7 +125,7 @@ struct AppleDocumentationClientTests { do { _ = try await client.fetchType(named: "MissingType", technology: "MetricKit") Issue.record("Expected the request to fail") - } catch let error as DefaultAppleDocumentationClient.Error { + } catch let error as AppleDocumentationClientError { // -- Assert -- #expect(error == .httpStatus(500)) } diff --git a/Tests/CLITests/client/AppleDocumentationClientPageTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryPageTests.swift similarity index 94% rename from Tests/CLITests/client/AppleDocumentationClientPageTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryPageTests.swift index e31fb37..7c83671 100644 --- a/Tests/CLITests/client/AppleDocumentationClientPageTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryPageTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CLI @Suite("Documentation page requests") -struct AppleDocumentationClientPageTests { +struct AppleDocumentationRepositoryPageTests { @Test("preserves exact path spelling and response bytes without fetching fragments") func fetchesExactPage() async throws { // -- Arrange -- @@ -15,7 +15,7 @@ struct AppleDocumentationClientPageTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/Member.with.dots(_:).json")) let data = Data("{ \"newUpstreamField\": true }\n".utf8) let transport = HTTPTestTransport(responses: [url: .http(data: data)]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport) // -- Act -- @@ -48,7 +48,7 @@ struct AppleDocumentationClientPageTests { requestedURL: .http(statusCode: 404, data: Data()), catalogURL: .http(data: catalog), canonicalURL: .http(data: data), ]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport) // -- Act -- diff --git a/Tests/CLITests/client/AppleDocumentationClientRootTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryRootTests.swift similarity index 91% rename from Tests/CLITests/client/AppleDocumentationClientRootTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryRootTests.swift index 83e1064..24844f2 100644 --- a/Tests/CLITests/client/AppleDocumentationClientRootTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryRootTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CLI @Suite("Apple documentation root lookup") -struct AppleDocumentationClientRootTests { +struct AppleDocumentationRepositoryRootTests { @Test("maps a missing canonical root to unsupported technology guidance", arguments: [false, true]) func mapsMissingCanonicalRoot(search: Bool) async throws { // -- Arrange -- @@ -23,13 +23,13 @@ struct AppleDocumentationClientRootTests { catalogURL: .http(statusCode: 200, data: cryptoKitCatalogData), canonicalURL: .http(statusCode: 404, data: Data()), ]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) // -- Act -- await #expect( - throws: DefaultAppleDocumentationClient.Error.unsupportedTechnology( + throws: AppleDocumentationRepositoryError.unsupportedTechnology( name: "Apple CryptoKit", url: "https://developer.apple.com/documentation/cryptokit" ) ) { @@ -57,13 +57,13 @@ struct AppleDocumentationClientRootTests { rootURL: .http(statusCode: 404, data: Data()), catalogURL: .http(statusCode: 200, data: cryptoKitCatalogData), ]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) // -- Act -- await #expect( - throws: DefaultAppleDocumentationClient.Error.unsupportedTechnology( + throws: AppleDocumentationRepositoryError.unsupportedTechnology( name: "Apple CryptoKit", url: "https://developer.apple.com/documentation/cryptokit" ) ) { @@ -102,7 +102,7 @@ struct AppleDocumentationClientRootTests { catalogURL: .http(statusCode: 200, data: cryptoKitCatalogData), canonicalURL: .http(statusCode: 200, data: rootData), ]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) @@ -125,12 +125,12 @@ struct AppleDocumentationClientRootTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") ) let transport = HTTPTestTransport(responses: [rootURL: .http(statusCode: status, data: Data())]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) // -- Act -- - await #expect(throws: DefaultAppleDocumentationClient.Error.httpStatus(status)) { + await #expect(throws: AppleDocumentationClientError.httpStatus(status)) { if search { _ = try await client.searchTypes(query: "AES", technology: "CryptoKit") } else { diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift similarity index 95% rename from Tests/CLITests/client/AppleDocumentationClientSearchTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift index d6daef8..5b39bf9 100644 --- a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositorySearchTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CLI @Suite("Apple documentation type search client") -struct AppleDocumentationClientSearchTests { +struct AppleDocumentationRepositorySearchTests { @available(macOS 15, *) @Test("detailed search returns partial-result paths and successful matches") func reportsPartialResults() async throws { @@ -18,7 +18,7 @@ struct AppleDocumentationClientSearchTests { controlsURL: .http(statusCode: 404, data: Data()), stylesURL: .http(data: stylesSearchPage), ]) let recorder = ClientLogRecorder() - let client = DefaultAppleDocumentationClient(logger: recorder.logger(), dependencies: transport) + let client = makeDocumentationRepository(logger: recorder.logger(), dependencies: transport) // -- Act -- let result = try await client.searchTypes(query: "buttonstyle", technology: "SwiftUI") @@ -38,7 +38,7 @@ struct AppleDocumentationClientSearchTests { // -- Arrange -- let rootURL = try searchURL("swiftui") let transport = HTTPTestTransport(responses: [rootURL: .http(data: duplicateRootSearchPage)]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) @@ -60,7 +60,7 @@ struct AppleDocumentationClientSearchTests { let transport = HTTPTestTransport(responses: [ rootURL: .http(data: rootSearchPage), controlsURL: .failure(error), ]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) @@ -83,7 +83,7 @@ struct AppleDocumentationClientSearchTests { let rootURL = try searchURL("swiftui") let controlsURL = try searchURL("swiftui/controls") let stylesURL = try searchURL("swiftui/styles") - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport(responses: [ rootURL: .http(data: rootSearchPage), controlsURL: .http(data: controlsSearchPage), @@ -105,7 +105,7 @@ struct AppleDocumentationClientSearchTests { let rootURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [rootURL: .http(data: duplicateRootSearchPage)] @@ -138,7 +138,7 @@ struct AppleDocumentationClientSearchTests { "https://developer.apple.com/tutorials/data/documentation/swiftui/styles.json" ) ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [ @@ -175,7 +175,7 @@ struct AppleDocumentationClientSearchTests { let resolvedRootURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [ @@ -202,7 +202,7 @@ struct AppleDocumentationClientSearchTests { try searchURL("swiftui/controls"): .http(data: controlsSearchPage), try searchURL("swiftui/styles"): .http(data: stylesSearchPage), ]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport) // -- Act -- diff --git a/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryTechnologyTests.swift similarity index 96% rename from Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryTechnologyTests.swift index 5c26af5..90dc29e 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryTechnologyTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CLI @Suite("Apple documentation technology client") -struct AppleDocumentationClientTechnologyTests { +struct AppleDocumentationRepositoryTechnologyTests { @Test("fetches technologies from every catalog group") func fetchesTechnologies() async throws { // -- Arrange -- @@ -13,7 +13,7 @@ struct AppleDocumentationClientTechnologyTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") ) let transport = HTTPTestTransport(responses: [expectedURL: .http(data: technologyCatalogData)]) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport ) diff --git a/Tests/CLITests/repository/AppleDocumentationRepositoryTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryTests.swift new file mode 100644 index 0000000..faa25c4 --- /dev/null +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Logging +import Testing + +@testable import CLI + +@Suite("Semantic documentation repository") +struct AppleDocumentationRepositoryTests { + @Test("loads a semantic page while preserving exact destinations and response size") + func loadsExactPage() async throws { + // -- Arrange -- + let url = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/String/init(_:)-1.json")) + let data = Data(#"{"metadata":{"title":"init(_:)","symbolKind":"init"}}"#.utf8) + let transport = HTTPTestTransport(responses: [url: .http(data: data)]) + let logger = Logger(label: "test") { _ in SwiftLogNoOpLogHandler() } + let repository = DefaultAppleDocumentationRepository( + logger: logger, dependencies: DefaultAppleDocumentationClient(logger: logger, dependencies: transport)) + let destination = DocumentationDestination( + technology: "swift", path: "/documentation/swift/String/init(_:)-1", fragment: "discussion") + + // -- Act -- + let result = try await repository.fetchPage(at: destination) + + // -- Assert -- + #expect(result.page.title == "init(_:)") + #expect(result.page.destination == destination) + #expect(result.responseByteCount == data.count) + #expect(await transport.requestedURLs == [url]) + } + + @Test("rejects malformed semantic pages before rendering") + func rejectsMalformedPage() async throws { + // -- Arrange -- + let url = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/string.json")) + let logger = Logger(label: "test") { _ in SwiftLogNoOpLogHandler() } + let repository = DefaultAppleDocumentationRepository( + logger: logger, + dependencies: DefaultAppleDocumentationClient( + logger: logger, dependencies: HTTPTestTransport(responses: [url: .http(data: Data("{}".utf8))]))) + + // -- Act -- + let load = { try await repository.fetchPage(named: "String", technology: "Swift") } + + // -- Assert -- + await #expect(throws: DecodingError.self) { try await load() } + } +} diff --git a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift b/Tests/CLITests/repository/AppleDocumentationRepositoryTypeListTests.swift similarity index 96% rename from Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift rename to Tests/CLITests/repository/AppleDocumentationRepositoryTypeListTests.swift index fe10c85..04aed58 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift +++ b/Tests/CLITests/repository/AppleDocumentationRepositoryTypeListTests.swift @@ -5,14 +5,14 @@ import Testing @testable import CLI @Suite("Apple documentation type catalog client") -struct AppleDocumentationClientTypeListTests { +struct AppleDocumentationRepositoryTypeListTests { @Test("lists direct symbols from a technology root document") func listsTechnologyRootSymbols() async throws { // -- Arrange -- let rootURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [rootURL: .http(statusCode: 200, data: swiftDataRootData)] @@ -53,7 +53,7 @@ struct AppleDocumentationClientTypeListTests { let resolvedRootURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [ @@ -90,7 +90,7 @@ struct AppleDocumentationClientTypeListTests { let technologiesURL = try #require( URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") ) - let client = DefaultAppleDocumentationClient( + let client = makeDocumentationRepository( logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: HTTPTestTransport( responses: [ diff --git a/Tests/CLITests/repository/DocumentationRepositoryFixture.swift b/Tests/CLITests/repository/DocumentationRepositoryFixture.swift new file mode 100644 index 0000000..1699089 --- /dev/null +++ b/Tests/CLITests/repository/DocumentationRepositoryFixture.swift @@ -0,0 +1,13 @@ +import Foundation +import Logging + +@testable import CLI + +func makeDocumentationRepository( + logger: Logger, dependencies: Transport, + baseURL: URL = URL(string: "https://developer.apple.com/tutorials/data/")! +) -> DefaultAppleDocumentationRepository> { + DefaultAppleDocumentationRepository( + logger: logger, + dependencies: DefaultAppleDocumentationClient(logger: logger, dependencies: dependencies, baseURL: baseURL)) +} diff --git a/Tests/CLITests/telemetry/SentryConfigurationTests.swift b/Tests/CLITests/telemetry/SentryConfigurationTests.swift index 36e7361..cb28bc1 100644 --- a/Tests/CLITests/telemetry/SentryConfigurationTests.swift +++ b/Tests/CLITests/telemetry/SentryConfigurationTests.swift @@ -54,7 +54,7 @@ import Testing @Test("treats documentation lookup failures as expected command errors") func treatsLookupFailureAsExpected() { // -- Arrange -- - let error = DefaultAppleDocumentationClient.Error.typeNotFound( + let error = AppleDocumentationRepositoryError.typeNotFound( name: "Model", technology: "SwiftData", suggestion: nil,