diff --git a/CHANGELOG.md b/CHANGELOG.md index ceef18d..6d0aa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to NetworkingKit are documented in this file. +## 2.4.6 - 2026-07-23 + +### Changed + +- Show GraphQL `/graphql` and `POST` metadata together with statically discoverable `query`, `variables`, and `operationName` parameters in generated backend HTML documentation. +- Render GraphQL query and variables as formatted, indented code blocks. + ## 2.4.5 - 2026-07-23 ### Changed diff --git a/Docs/BackendReferencePlugin.md b/Docs/BackendReferencePlugin.md index 6c029ce..1e24fd4 100644 --- a/Docs/BackendReferencePlugin.md +++ b/Docs/BackendReferencePlugin.md @@ -89,7 +89,7 @@ struct GetProfileRequest: AccountRequest, RestfulRequest { - A `NetworkClient` or `SharedNetworkClient` `baseURL` identifies one server. - A request protocol constrained to one concrete client associates request types with that server. -- `RestfulRequest` and `GraphQLRequest` declarations become endpoints. +- `RestfulRequest` and `GraphQLRequest` declarations become endpoints. GraphQL endpoints use `/graphql` and `POST`; their `query`, `variables`, and `operationName` declarations are shown as request parameters when statically available. - The closest preceding `// MARK: - Feature name` groups the endpoints. - Stored request properties become parameters. The client `configuration` becomes the Configuration table. diff --git a/Docs/BackendReferencePlugin.zh-Hans.md b/Docs/BackendReferencePlugin.zh-Hans.md index e9697d9..30e1717 100644 --- a/Docs/BackendReferencePlugin.zh-Hans.md +++ b/Docs/BackendReferencePlugin.zh-Hans.md @@ -89,7 +89,7 @@ struct GetProfileRequest: AccountRequest, RestfulRequest { - `NetworkClient` / `SharedNetworkClient` 的 `baseURL` 表示一个后端服务器。 - 绑定到某个具体 Client 的请求协议,会将 Request 关联到对应服务器。 -- `RestfulRequest` 和 `GraphQLRequest` 声明会被识别为端点。 +- `RestfulRequest` 和 `GraphQLRequest` 声明会被识别为端点。GraphQL 端点固定为 `/graphql` 和 `POST`;能够静态识别的 `query`、`variables` 与 `operationName` 会展示在参数列中。 - 请求声明上方最近的 `// MARK: - Feature 名称` 用于 Feature 分组。 - Request 的存储属性会列为参数;Client 的 `configuration` 会生成配置表。 diff --git a/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html b/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html index ffeb7be..56b125d 100644 --- a/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html +++ b/Examples/NetworkingKitDemo/BackendAPIReference/AppNetworkClient.html @@ -1,4 +1,12 @@ AppNetworkClient
← 所有服务器

BACKEND SERVER

AppNetworkClient

https://rickandmortyapi.com · 2 个端点

CLIENT CONFIGURATION

配置

配置项
timeoutInterval15
retryPolicyRetryPolicy(maxAttempts: 2)
errorLocalizerAppNetworkErrorLocalizer()

FEATURE

GraphQL

1 个端点
MethodEndpointKindParametersRequestSource
POST/graphqlGraphQLid: StringFetchCharacterProfileRequestDemoViewModel.swift
+
← 所有服务器

BACKEND SERVER

AppNetworkClient

https://rickandmortyapi.com · 2 个端点

CLIENT CONFIGURATION

配置

配置项
timeoutInterval15
retryPolicyRetryPolicy(maxAttempts: 2)
errorLocalizerAppNetworkErrorLocalizer()

FEATURE

GraphQL

1 个端点
MethodEndpointKindParametersRequestSource
POST/graphqlGraphQLid: String
query
query Character($id: ID!) {
+  character(id: $id) {
+    name
+    species
+    status
+  }
+}

variables
[
+  "id": AnyEncodable(id)
+]
FetchCharacterProfileRequestDemoViewModel.swift

FEATURE

REST

1 个端点
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 index 34c6dbf..ef87fa7 100644 --- a/Examples/NetworkingKitDemo/BackendAPIReference/index.html +++ b/Examples/NetworkingKitDemo/BackendAPIReference/index.html @@ -1,3 +1,3 @@ Backend API Reference

NETWORKINGKIT

后端 API 文档

由 App 源码自动生成 · 共 1 个后端服务器,2 个端点

1 个服务器2 个端点
BACKEND SERVER

AppNetworkClient

https://rickandmortyapi.com2 个端点
\ No newline at end of file +

NETWORKINGKIT

后端 API 文档

由 App 源码自动生成 · 共 1 个后端服务器,2 个端点

1 个服务器2 个端点
BACKEND SERVER

AppNetworkClient

https://rickandmortyapi.com2 个端点
\ No newline at end of file diff --git a/README.md b/README.md index cc01ba5..61020fa 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.4.5") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.6") ] ``` diff --git a/README.zh-Hans.md b/README.zh-Hans.md index aaa545f..8d00868 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.4.5") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.6") ] ``` diff --git a/Sources/BackendReferenceGenerator/main.swift b/Sources/BackendReferenceGenerator/main.swift index 29cc5b5..41a4bd1 100644 --- a/Sources/BackendReferenceGenerator/main.swift +++ b/Sources/BackendReferenceGenerator/main.swift @@ -162,13 +162,14 @@ enum BackendReferenceGenerator { 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))" } + var 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" + params.append(contentsOf: graphQLParameters(in: body, excluding: params)) } 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() } ?? "" @@ -180,6 +181,143 @@ enum BackendReferenceGenerator { return endpoints } + static func graphQLParameters(in body: String, excluding existing: [String]) -> [String] { + // GraphQLRequest serializes query, variables, and operationName into its JSON body. + // Capture both stored and single-line computed declarations; multi-line expressions + // remain explicitly dynamic rather than pretending to know their runtime value. + let member = try! NSRegularExpression(pattern: #"(?m)^\s*(?:private\s+|public\s+|internal\s+)?(?:let|var)\s+(query|variables|operationName)\b(?:\s*:\s*[^=\{\n]+)?\s*(?:=\s*([^\n]+)|\{\s*(.*)\}\s*$)"#) + var names = Set(existing.compactMap { $0.split(separator: ":", maxSplits: 1).first.map(String.init) }) + var parameters: [String] = [] + for match in member.matches(in: body, range: NSRange(body.startIndex..., in: body)) { + let name = capture(1, from: body, match: match) + guard names.insert(name).inserted else { continue } + let expression = captureOptional(2, from: body, match: match) + ?? captureOptional(3, from: body, match: match) + ?? "" + parameters.append("\(name): \(expression.trimmingCharacters(in: .whitespaces))") + } + if !names.contains("query"), body.range(of: #"\b(?:let|var)\s+query\b"#, options: .regularExpression) != nil { + parameters.append("query: ") + } + return parameters + } + + static func parameterHTML(_ parameter: String) -> String { + if parameter.hasPrefix("query: ") { + let source = String(parameter.dropFirst("query: ".count)) + return graphQLCodeBlock(name: "query", source: formatGraphQLQuery(source)) + } + if parameter.hasPrefix("variables: ") { + let source = String(parameter.dropFirst("variables: ".count)) + return graphQLCodeBlock(name: "variables", source: formatGraphQLVariables(source)) + } + return escape(parameter) + } + + static func graphQLCodeBlock(name: String, source: String) -> String { + "
\(escape(name))
\(escape(source))
" + } + + static func formatGraphQLQuery(_ source: String) -> String { + var query = source.trimmingCharacters(in: .whitespacesAndNewlines) + if query.hasPrefix("\"") && query.hasSuffix("\"") && query.count >= 2 { + query.removeFirst() + query.removeLast() + } + query = query.replacingOccurrences(of: #"\n"#, with: "\n").replacingOccurrences(of: #"\""#, with: "\"") + + var lines: [String] = [] + var current = "" + var indentation = 0 + var parenthesisDepth = 0 + func appendCurrent() { + let trimmed = current.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + lines.append(String(repeating: " ", count: indentation) + trimmed) + current = "" + } + + for character in query { + switch character { + case "(": + parenthesisDepth += 1 + current.append(character) + case ")": + parenthesisDepth = max(0, parenthesisDepth - 1) + current.append(character) + case "{": + let trimmed = current.trimmingCharacters(in: .whitespaces) + lines.append(String(repeating: " ", count: indentation) + (trimmed.isEmpty ? "{" : trimmed + " {")) + current = "" + indentation += 1 + case "}": + appendCurrent() + indentation = max(0, indentation - 1) + lines.append(String(repeating: " ", count: indentation) + "}") + case " ", "\t", "\n": + if indentation > 0 && parenthesisDepth == 0 { + appendCurrent() + } else if !current.hasSuffix(" ") { + current.append(" ") + } + default: + current.append(character) + } + } + appendCurrent() + var normalized: [String] = [] + for line in lines { + if line.trimmingCharacters(in: .whitespaces) == "{", !normalized.isEmpty { + normalized[normalized.count - 1] += " {" + } else { + normalized.append(line) + } + } + return normalized.joined(separator: "\n") + } + + static func formatGraphQLVariables(_ source: String) -> String { + var lines: [String] = [] + var current = "" + var indentation = 0 + var parenthesisDepth = 0 + func appendCurrent() { + let trimmed = current.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + lines.append(String(repeating: " ", count: indentation) + trimmed) + current = "" + } + + for character in source.trimmingCharacters(in: .whitespacesAndNewlines) { + switch character { + case "(": + parenthesisDepth += 1 + current.append(character) + case ")": + parenthesisDepth = max(0, parenthesisDepth - 1) + current.append(character) + case "[", "{": + let trimmed = current.trimmingCharacters(in: .whitespaces) + lines.append(String(repeating: " ", count: indentation) + trimmed + String(character)) + current = "" + indentation += 1 + case "]", "}": + appendCurrent() + indentation = max(0, indentation - 1) + lines.append(String(repeating: " ", count: indentation) + String(character)) + case "," where parenthesisDepth == 0: + current.append(character) + appendCurrent() + case " ", "\t", "\n": + if !current.hasSuffix(" ") { current.append(" ") } + default: + current.append(character) + } + } + appendCurrent() + return lines.joined(separator: "\n") + } + 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 } @@ -200,7 +338,7 @@ enum BackendReferenceGenerator { 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: "
") + let parameters = endpoint.parameters.isEmpty ? "—" : endpoint.parameters.map(parameterHTML).joined(separator: "
") return "\(escape(endpoint.method))\(escape(endpoint.path))\(escape(endpoint.requestKind))\(parameters)\(escape(endpoint.name))\(escape(endpoint.source))" }.joined() let search = endpoints.map { $0.feature + " " + $0.name + " " + $0.path + " " + $0.parameters.joined(separator: " ") }.joined(separator: " ") @@ -327,11 +465,16 @@ enum BackendReferenceGenerator { 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 captureOptional(_ index: Int, from string: String, match: NSTextCheckingResult) -> String? { + let range = match.range(at: index) + guard range.location != NSNotFound, let swiftRange = Range(range, in: string) else { return nil } + return String(string[swiftRange]) + } 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) + \(body) """ } }