diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ff0d9a..7fbce8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to NetworkingKit are documented in this file. +## 2.4.0 - 2026-07-23 + +### Added + +- Add `BackendReferencePlugin` and its generator for build-time HTML references of app backend servers, configuration values, feature-grouped endpoints, request parameters, and source locations. +- Add generated backend-reference HTML to the NetworkingKit Demo. + ## 2.3.8 - 2026-07-19 ### Changed diff --git a/Docs/BackendReferencePlugin.zh-Hans.md b/Docs/BackendReferencePlugin.zh-Hans.md new file mode 100644 index 0000000..06775c6 --- /dev/null +++ b/Docs/BackendReferencePlugin.zh-Hans.md @@ -0,0 +1,54 @@ +# 后端 API HTML 索引 + +`BackendReferencePlugin` 会在每次编译 App Target 时扫描 Swift 源码,生成后端服务器、Feature、端点、请求参数与网络配置的 HTML 索引。默认推荐使用 Build Tool Plugin:无需维护 `--package-path` 或额外脚本,且始终与本次构建使用相同的源码。 + +## 推荐:Build Tool Plugin + +### Xcode 配置步骤 + +1. 使用 **File → Add Package Dependencies…** 将 `NetworkingKit` 添加到工程。 +2. 选中 App Target,打开 **Build Phases**。 +3. 在 **Run Build Tool Plug-ins** 区域点击 `+`,选择 `BackendReferencePlugin`。 +4. 直接 Build App。构建日志会显示 **Generate backend API reference**。 +5. 在 Xcode Report navigator 中展开该构建步骤;HTML 位于该 Target 对应的 Derived Data 插件输出目录,入口文件为 `BackendAPIReference/index.html`。在 Finder 中打开该目录即可浏览文档。 + +Build Tool Plugin 受 SwiftPM 沙盒保护,只能写入 Derived Data 的插件工作目录,不能写回 App 的源码根目录。这是它无需路径配置、可安全在本地与 CI 自动运行的原因。 + +## 备用:导出到 App 根目录 + +只有需要将 HTML 固定保存为 `$SRCROOT/BackendAPIReference/`、提交到制品库或交付给非 Xcode 用户时,才在 App Target 最后添加一个 **Run Script Build Phase**。该备用方式需要为 Package checkout 提供路径: + +```sh +swift run --package-path "$NETWORKING_KIT_CHECKOUT" BackendReferenceGenerator \ + --source-directory "$SRCROOT" \ + --output-directory "$SRCROOT/BackendAPIReference" \ + --stamp "$DERIVED_FILE_DIR/backend-reference-export.stamp" +``` + +配置步骤: + +1. 在 Scheme 或 CI 中定义 `NETWORKING_KIT_CHECKOUT`,值为 `NetworkingKit` 本地 checkout 的绝对路径。 +2. 在 App Target 的 **Build Phases** 点击 `+ → New Run Script Phase`,并将该步骤放在编译步骤之后。 +3. 粘贴上面的脚本,Build 一次。 +4. 打开 `$SRCROOT/BackendAPIReference/index.html`。该目录只包含开发文档,不应加入 App bundle;如不需要提交,可加入 `.gitignore`。 + +如果只需要在开发和 CI 中查看文档,请保持仅使用 Build Tool Plugin;Run Script 是输出位置必须为 App 根目录时的次要方案。 + +## 识别规则 + +- `NetworkClient` / `SharedNetworkClient` 的 `baseURL` 表示一个后端服务器。 +- `protocol AppNetworkRequest: NetworkRequest where Client == AppNetworkClient` 将请求关联至服务器。 +- `RestfulRequest` 和 `GraphQLRequest` 会被识别为端点。 +- 请求声明上方最近的 `// MARK: - Feature 名称` 作为 Feature 分组。 +- 请求的存储属性列为参数,客户端 `configuration` 会在服务器页展示。 + +```swift +// MARK: - Characters +struct GetCharacterRequest: AppNetworkRequest, RestfulRequest { + private let id: String + var path: String { "/api/character/\(id)" } + var method: HTTPMethod { .get } +} +``` + +静态扫描不会执行 Swift 代码。动态 URL、HTTP 方法及复杂表达式会保留源码形式或显示为 ``。 diff --git a/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html b/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html new file mode 100644 index 0000000..2a9e429 --- /dev/null +++ b/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html @@ -0,0 +1,2 @@ +AppNetworkClient

← All servers

AppNetworkClient

Base URL: https://rickandmortyapi.com

Configuration

ConfigurationValue
timeoutInterval15
retryPolicyRetryPolicy(maxAttempts: 2)
errorLocalizerAppNetworkErrorLocalizer()

GraphQL

MethodEndpointKindParametersRequestSource
POST/graphqlGraphQLid: StringFetchCharacterProfileRequestDemoViewModel.swift
+

REST

MethodEndpointKindParametersRequestSource
GET"/api/character/\(id)"RESTid: StringGetCharacterRequestDemoViewModel.swift
\ No newline at end of file diff --git a/Examples/NetworkingKitDemo/BackendAPIReference/index.html b/Examples/NetworkingKitDemo/BackendAPIReference/index.html new file mode 100644 index 0000000..52ade8e --- /dev/null +++ b/Examples/NetworkingKitDemo/BackendAPIReference/index.html @@ -0,0 +1 @@ +Backend API Reference

Backend API Reference

Generated from the app source during build.

\ No newline at end of file diff --git a/Package.swift b/Package.swift index 04fabcd..8fd4272 100644 --- a/Package.swift +++ b/Package.swift @@ -22,12 +22,26 @@ let package = Package( name: "NetworkingKit", targets: ["NetworkingKit"] ), + .plugin( + name: "BackendReferencePlugin", + targets: ["BackendReferencePlugin"] + ), ], targets: [ .target( name: "NetworkingKit", path: "Sources/NetworkingKit" ), + .executableTarget( + name: "BackendReferenceGenerator", + path: "Sources/BackendReferenceGenerator" + ), + .plugin( + name: "BackendReferencePlugin", + capability: .buildTool(), + dependencies: ["BackendReferenceGenerator"], + path: "Plugins/BackendReferencePlugin" + ), .testTarget( name: "NetworkingKitTests", dependencies: ["NetworkingKit"], diff --git a/Plugins/BackendReferencePlugin/plugin.swift b/Plugins/BackendReferencePlugin/plugin.swift new file mode 100644 index 0000000..c63534c --- /dev/null +++ b/Plugins/BackendReferencePlugin/plugin.swift @@ -0,0 +1,26 @@ +import PackagePlugin +import Foundation + +@main +struct BackendReferencePlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] { + guard let sourceTarget = target as? SourceModuleTarget else { return [] } + + let tool = try context.tool(named: "BackendReferenceGenerator") + let stamp = context.pluginWorkDirectoryURL.appendingPathComponent("backend-reference.stamp") + let outputDirectory = context.pluginWorkDirectoryURL.appendingPathComponent("BackendAPIReference", isDirectory: true) + + return [ + .buildCommand( + displayName: "Generate backend API reference", + executable: tool.url, + arguments: [ + "--source-directory", sourceTarget.directory.string, + "--output-directory", outputDirectory.path, + "--stamp", stamp.path + ], + outputFiles: [stamp] + ) + ] + } +} diff --git a/README.md b/README.md index 693d8c0..23d7c35 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Or add the package manifest dependency: ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.8") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.0") ] ``` diff --git a/README.zh-Hans.md b/README.zh-Hans.md index d65ec10..4c2c334 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -33,7 +33,7 @@ https://github.com/relaxfinger/NetworkingKit.git ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.8") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.0") ] ``` @@ -139,6 +139,7 @@ README 只保留从安装到首个请求的最短路径。构建生产网络层 | 日志、追踪与指标 | [Observability](Docs/Observability.md) | [可观测性](Docs/Observability.zh-Hans.md) | | 稳定错误与本地化 UI 文案 | [Errors](Docs/Errors.md) | [错误与本地化](Docs/Errors.zh-Hans.md) | | 证书和公钥 pinning | [Security](Docs/Security.md) | [传输安全](Docs/Security.zh-Hans.md) | +| 后端服务器、端点与参数 HTML 索引 | — | [后端 API HTML 索引](Docs/BackendReferencePlugin.zh-Hans.md) | ## Demo diff --git a/Sources/BackendReferenceGenerator/main.swift b/Sources/BackendReferenceGenerator/main.swift new file mode 100644 index 0000000..1ec473c --- /dev/null +++ b/Sources/BackendReferenceGenerator/main.swift @@ -0,0 +1,270 @@ +import Foundation + +struct Server { + let name: String + let baseURL: String + let configuration: String +} + +struct Endpoint { + let name: String + let server: String + let feature: String + let method: String + let path: String + let parameters: [String] + let requestKind: String + let source: String +} + +@main +enum BackendReferenceGenerator { + static func main() throws { + let arguments = Array(CommandLine.arguments.dropFirst()) + guard let sourceDirectory = value(after: "--source-directory", in: arguments), + let stamp = value(after: "--stamp", in: arguments) else { + throw GeneratorError.invalidArguments + } + + let sourceURL = URL(fileURLWithPath: sourceDirectory) + let projectRoot = findProjectRoot(startingAt: sourceURL) + let outputDirectory = value(after: "--output-directory", in: arguments) + .map(URL.init(fileURLWithPath:)) + ?? projectRoot.appendingPathComponent("BackendAPIReference", isDirectory: true) + let files = try swiftFiles(in: projectRoot) + let documents = try files.map { url in (url, try String(contentsOf: url, encoding: .utf8)) } + let servers = parseServers(documents) + let protocolClients = parseProtocolClients(documents) + let endpoints = parseEndpoints(documents, protocolClients: protocolClients, root: projectRoot) + + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + try writeIndex(servers: servers, endpoints: endpoints, to: outputDirectory) + for server in servers { + try writeServerPage(server, endpoints: endpoints.filter { $0.server == server.name }, to: outputDirectory) + } + try "Generated by BackendReferencePlugin\n".write(to: URL(fileURLWithPath: stamp), atomically: true, encoding: .utf8) + } + + static func value(after option: String, in arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: option), arguments.indices.contains(index + 1) else { return nil } + return arguments[index + 1] + } + + static func findProjectRoot(startingAt directory: URL) -> URL { + let fileManager = FileManager.default + var candidate = directory + while candidate.path != "/" { + let entries = (try? fileManager.contentsOfDirectory(atPath: candidate.path)) ?? [] + if entries.contains("Package.swift") || entries.contains(where: { $0.hasSuffix(".xcodeproj") }) { + return candidate + } + candidate.deleteLastPathComponent() + } + return directory + } + + static func swiftFiles(in root: URL) throws -> [URL] { + let skipped = Set([".build", ".git", "BackendAPIReference", "Tests", "DerivedData"]) + let keys: Set = [.isRegularFileKey, .isDirectoryKey] + let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: Array(keys), options: [.skipsHiddenFiles]) + var files: [URL] = [] + while let url = enumerator?.nextObject() as? URL { + let values = try url.resourceValues(forKeys: keys) + if values.isDirectory == true, skipped.contains(url.lastPathComponent) { + enumerator?.skipDescendants() + } else if values.isRegularFile == true, url.pathExtension == "swift" { + files.append(url) + } + } + return files + } + + static func parseServers(_ documents: [(URL, String)]) -> [Server] { + var servers: [Server] = [] + let declaration = try! NSRegularExpression(pattern: #"(?s)(?:final\s+)?(?:class|struct)\s+(\w+)\s*:\s*[^\{]*(?:NetworkClient|SharedNetworkClient)[^\{]*\{(.*?)\n\}"#) + let baseURL = try! NSRegularExpression(pattern: #"baseURL\s*=\s*URL\s*\(\s*string:\s*\"([^\"]+)\"\s*\)\s*!"#) + let configuration = try! NSRegularExpression(pattern: #"configuration\s*=\s*([^\n]+)"#) + for (_, source) in documents { + for match in declaration.matches(in: source, range: NSRange(source.startIndex..., in: source)) { + let body = capture(2, from: source, match: match) + guard let urlMatch = baseURL.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)) else { continue } + let name = capture(1, from: source, match: match) + let config = configuration.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)).map { capture(1, from: body, match: $0).trimmingCharacters(in: .whitespaces) } ?? "NetworkConfiguration.default" + let resolvedConfiguration = resolveConfiguration(config, in: documents) + servers.append(Server(name: name, baseURL: capture(1, from: body, match: urlMatch), configuration: resolveStaticReferences(in: resolvedConfiguration, documents: documents))) + } + } + return servers + } + + static func resolveConfiguration(_ reference: String, in documents: [(URL, String)]) -> String { + guard let typeName = reference.split(separator: ".").first, reference.hasSuffix(".default") else { return reference } + let declaration = try! NSRegularExpression(pattern: #"(?s)(?:enum|struct|class)\s+\#(typeName)\s*\{(.*?)\n\}"#) + let defaultValue = try! NSRegularExpression(pattern: #"(?s)static\s+let\s+`?default`?\s*=\s*(NetworkConfiguration\(.*?\n\s*\))"#) + for (_, source) in documents { + guard let declarationMatch = declaration.firstMatch(in: source, range: NSRange(source.startIndex..., in: source)) else { continue } + let body = capture(1, from: source, match: declarationMatch) + if let valueMatch = defaultValue.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)) { + return capture(1, from: body, match: valueMatch).replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + } + } + return reference + } + + static func parseProtocolClients(_ documents: [(URL, String)]) -> [String: String] { + let expression = try! NSRegularExpression(pattern: #"(?:protocol|extension)\s+(\w+)\s*:\s*NetworkRequest\s+where\s+Client\s*==\s*(\w+)"#) + var clients: [String: String] = [:] + for (_, source) in documents { + for match in expression.matches(in: source, range: NSRange(source.startIndex..., in: source)) { + clients[capture(1, from: source, match: match)] = capture(2, from: source, match: match) + } + } + return clients + } + + static func parseEndpoints(_ documents: [(URL, String)], protocolClients: [String: String], root: URL) -> [Endpoint] { + let declaration = try! NSRegularExpression(pattern: #"(?s)(?:public|private|internal)?\s*(?:final\s+)?(?:struct|class)\s+(\w+)\s*:\s*([^\{]+)\{(.*?)\n\}"#) + // A stored property ends at `=` or a newline. This intentionally excludes + // computed request members such as `var path: String { ... }`. + let property = try! NSRegularExpression(pattern: #"(?:private\s+|public\s+|internal\s+)?(?:let|var)\s+(\w+)\s*:\s*([^=\n\{]+)(?=\s*(?:=|\n))"#) + let path = try! NSRegularExpression(pattern: #"var\s+path\s*:\s*String\s*\{\s*([^\}]+)\}"#) + let method = try! NSRegularExpression(pattern: #"var\s+method\s*:\s*HTTPMethod\s*\{\s*\.([a-zA-Z]+)\s*\}"#) + var endpoints: [Endpoint] = [] + + for (url, source) in documents { + for match in declaration.matches(in: source, range: NSRange(source.startIndex..., in: source)) { + let inherited = capture(2, from: source, match: match) + let kind: String + if inherited.contains("RestfulRequest") { kind = "REST" } + else if inherited.contains("GraphQLRequest") { kind = "GraphQL" } + else { continue } + guard let protocolName = protocolClients.keys.first(where: { inherited.contains($0) }), let server = protocolClients[protocolName] else { continue } + let body = capture(3, from: source, match: match) + let name = capture(1, from: source, match: match) + let params = property.matches(in: body, range: NSRange(body.startIndex..., in: body)).map { "\(capture(1, from: body, match: $0)): \(capture(2, from: body, match: $0).trimmingCharacters(in: .whitespaces))" } + let feature = feature(before: match.range.location, in: source) + let relativePath: String + let httpMethod: String + if kind == "GraphQL" { + relativePath = "/graphql" + httpMethod = "POST" + } else { + relativePath = path.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)).map { capture(1, from: body, match: $0).trimmingCharacters(in: .whitespaces) } ?? "" + httpMethod = method.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)).map { capture(1, from: body, match: $0).uppercased() } ?? "" + } + let location = url.path.replacingOccurrences(of: root.path + "/", with: "") + endpoints.append(Endpoint(name: name, server: server, feature: feature, method: httpMethod, path: relativePath, parameters: params, requestKind: kind, source: location)) + } + } + return endpoints + } + + static func feature(before offset: Int, in source: String) -> String { + let prefix = String(source.prefix(offset)) + let marks = prefix.components(separatedBy: .newlines).filter { $0.range(of: #"^\s*//\s*MARK:\s*-\s*"#, options: .regularExpression) != nil } + guard let mark = marks.last else { return "Other" } + return mark.replacingOccurrences(of: #"^\s*//\s*MARK:\s*-\s*"#, with: "", options: .regularExpression).trimmingCharacters(in: .whitespaces) + } + + static func writeIndex(servers: [Server], endpoints: [Endpoint], to directory: URL) throws { + let cards = servers.map { server in + let filename = pageName(for: server) + return "
  • \(escape(server.name))\(escape(server.baseURL))\(endpoints.filter { $0.server == server.name }.count) endpoints
  • " + }.joined(separator: "\n") + try html(title: "Backend API Reference", body: "

    Backend API Reference

    Generated from the app source during build.

      \(cards)
    ").write(to: directory.appendingPathComponent("index.html"), atomically: true, encoding: .utf8) + } + + static func writeServerPage(_ server: Server, endpoints: [Endpoint], to directory: URL) throws { + let groups = Dictionary(grouping: endpoints, by: \.feature).sorted { $0.key < $1.key }.map { feature, endpoints in + let rows = endpoints.sorted { $0.name < $1.name }.map { endpoint in + let parameters = endpoint.parameters.isEmpty ? "—" : endpoint.parameters.map(escape).joined(separator: "
    ") + return "\(escape(endpoint.method))\(escape(endpoint.path))\(escape(endpoint.requestKind))\(parameters)\(escape(endpoint.name))\(escape(endpoint.source))" + }.joined() + return "

    \(escape(feature))

    \(rows)
    MethodEndpointKindParametersRequestSource
    " + }.joined(separator: "\n") + let configurationRows = configurationItems(from: server.configuration).map { key, value in + "\(escape(key))\(escape(value))" + }.joined() + let configurationTable = "

    Configuration

    \(configurationRows)
    ConfigurationValue
    " + let body = "

    ← All servers

    \(escape(server.name))

    Base URL: \(escape(server.baseURL))

    \(configurationTable)\(groups.isEmpty ? "

    No statically discoverable endpoints.

    " : groups)" + try html(title: server.name, body: body).write(to: directory.appendingPathComponent(pageName(for: server)), atomically: true, encoding: .utf8) + } + + static func configurationItems(from configuration: String) -> [(String, String)] { + guard configuration.hasPrefix("NetworkConfiguration("), configuration.hasSuffix(")") else { + return [("Configuration", configuration)] + } + let inner = String(configuration.dropFirst("NetworkConfiguration(".count).dropLast()) + var items: [String] = [] + var current = "" + var depth = 0 + for character in inner { + switch character { + case "(", "[", "{": depth += 1 + case ")", "]", "}": depth -= 1 + case "," where depth == 0: + items.append(current) + current = "" + continue + default: break + } + current.append(character) + } + if !current.isEmpty { items.append(current) } + return items.map { item in + let pair = item.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2 else { return (item.trimmingCharacters(in: .whitespaces), "") } + return (pair[0].trimmingCharacters(in: .whitespaces), pair[1].trimmingCharacters(in: .whitespaces)) + } + } + + static func resolveStaticReferences(in value: String, documents: [(URL, String)]) -> String { + // Values in NetworkConfiguration often use app constants, for example + // `DemoConstants.requestTimeout`. Resolve those references without + // evaluating arbitrary Swift code. + let reference = try! NSRegularExpression(pattern: #"\b(\w+)\.(\w+)\b"#) + var resolved = value + for _ in 0..<4 { + let matches = reference.matches(in: resolved, range: NSRange(resolved.startIndex..., in: resolved)) + var replacements: [(NSRange, String)] = [] + for match in matches { + let type = capture(1, from: resolved, match: match) + let member = capture(2, from: resolved, match: match) + if let constant = staticConstant(named: member, in: type, documents: documents) { + replacements.append((match.range, constant)) + } + } + guard !replacements.isEmpty else { break } + for (range, replacement) in replacements.reversed() { + guard let swiftRange = Range(range, in: resolved) else { continue } + resolved.replaceSubrange(swiftRange, with: replacement) + } + } + return resolved + } + + static func staticConstant(named member: String, in type: String, documents: [(URL, String)]) -> String? { + let declaration = try! NSRegularExpression( + pattern: #"(?s)(?:enum|struct|class)\s+\#(type)\s*\{(.*?)^\}"#, + options: [.anchorsMatchLines] + ) + let constant = try! NSRegularExpression( + pattern: #"(?m)^\s*static\s+let\s+\#(member)(?:\s*:\s*[^=\n]+)?\s*=\s*([^\n]+)"# + ) + for (_, source) in documents { + guard let typeMatch = declaration.firstMatch(in: source, range: NSRange(source.startIndex..., in: source)) else { continue } + let body = capture(1, from: source, match: typeMatch) + if let valueMatch = constant.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)) { + return capture(1, from: body, match: valueMatch).trimmingCharacters(in: .whitespaces) + } + } + return nil + } + + static func pageName(for server: Server) -> String { server.name + ".html" } + static func capture(_ index: Int, from string: String, match: NSTextCheckingResult) -> String { String(string[Range(match.range(at: index), in: string)!]) } + static func escape(_ value: String) -> String { value.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """) } + static func html(title: String, body: String) -> String { "\(escape(title))\(body)" } +} + +enum GeneratorError: Error { case invalidArguments }