Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to NetworkingKit are documented in this file.

## 2.4.7 - 2026-07-23

### Fixed

- Recognize and format multi-line triple-quoted GraphQL queries and multi-line variables in generated backend HTML documentation.
- Add Demo and request-body test coverage for multi-line GraphQL queries.

## 2.4.6 - 2026-07-23

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AppNetworkClient</title><style>
:root{color-scheme:dark;--bg:#101112;--surface:#171819;--line:#343638;--text:#f7f7f5;--muted:#96999d;--blue:#8cbfff;--blue-bg:#172944}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px -apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif}.page-width{max-width:1788px;margin:auto;padding-left:clamp(24px,5.4vw,104px);padding-right:clamp(24px,5.4vw,104px)}.hero{background:#0e1822;border-bottom:1px solid var(--line);padding:88px 0 68px}.hero.compact{padding:46px 0}.eyebrow,.card-kicker{margin:0 0 12px;color:var(--muted);font-size:13px;font-weight:750;letter-spacing:.12em}.hero h1{margin:0;font-size:clamp(44px,5vw,78px);line-height:1.05;letter-spacing:-.055em}.lede{margin:24px 0 0;color:var(--muted);font-size:21px;font-weight:600}.back{display:inline-block;margin-bottom:26px;color:var(--blue);font-weight:700;text-decoration:none}main{padding-top:62px;padding-bottom:100px}.search{width:100%;border:3px solid var(--blue);outline:0;border-radius:17px;background:#141516;color:var(--text);padding:20px 22px;font:600 21px inherit;box-shadow:0 0 0 1px #29486f}.search::placeholder{color:#6f7378}.chip-row{display:flex;gap:12px;margin:34px 0 42px}.chip,.count-badge{display:inline-flex;align-items:center;border-radius:999px;background:var(--blue-bg);color:#dceaff;font-weight:750}.chip{padding:10px 15px;border:1px solid #29486f}.count-badge{padding:9px 13px;font-size:14px;white-space:nowrap}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:22px}.server-card,.feature-card{border:1px solid var(--line);border-radius:22px;background:var(--surface);box-shadow:0 15px 42px #0000002b}.server-card{display:block;min-height:220px;padding:31px;color:var(--text);text-decoration:none;transition:transform .15s,border-color .15s}.server-card:hover{transform:translateY(-3px);border-color:var(--blue)}.server-card h2,.feature-heading h2{margin:0;font-size:30px;letter-spacing:-.035em}.server-card code{display:block;margin-top:18px;color:#c8d6e9;overflow-wrap:anywhere}.server-card .count-badge{margin-top:26px}.feature-list{display:grid;gap:28px;margin-top:34px}.feature-card{margin-top:34px;padding:30px}.feature-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:30px}.table-wrap{overflow:auto}table{width:100%;min-width:820px;border-collapse:collapse;text-align:left}th{color:var(--muted);font-size:14px;letter-spacing:.07em;text-transform:uppercase}th,td{padding:17px;border-bottom:1px solid var(--line);vertical-align:top}tbody tr:last-child td{border-bottom:0}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em;overflow-wrap:anywhere}.method{display:inline-block;border-radius:7px;padding:6px 8px;background:#24364f;color:#cfe4ff;font:750 12px ui-monospace,SFMono-Regular,Menlo}.method-post{background:#263f32;color:#b6e5c6}.method-delete{background:#482c31;color:#ffc5cb}.empty{margin:40px 0;color:var(--muted);text-align:center}@media(max-width:700px){.hero{padding:58px 0 48px}.lede{font-size:17px}.feature-card{padding:21px}.feature-heading{display:block}.feature-heading .count-badge{margin-top:16px}.search{font-size:17px;padding:17px}}
</style><style>.graphql-input{display:grid;gap:7px}.graphql-input>span{color:var(--muted);font:750 12px ui-monospace,SFMono-Regular,Menlo}.graphql-input pre{max-width:560px;margin:0;padding:13px 15px;overflow:auto;border:1px solid #29486f;border-radius:10px;background:#101924;white-space:pre}.graphql-input pre code{color:#dceaff;font-size:13px;line-height:1.55}</style></head><body><header class="hero compact"><div class="page-width"><a class="back" href="index.html">← 所有服务器</a><p class="eyebrow">BACKEND SERVER</p><h1>AppNetworkClient</h1><p class="lede"><code>https://rickandmortyapi.com</code> · 2 个端点</p></div></header><main class="page-width"><input class="search" type="search" placeholder="筛选 Feature、端点、参数、Request 或 Source…" aria-label="筛选端点" data-filter><section class="feature-card configuration"><div class="feature-heading"><div><p class="card-kicker">CLIENT CONFIGURATION</p><h2>配置</h2></div></div><div class="table-wrap"><table><thead><tr><th>配置项</th><th>值</th></tr></thead><tbody><tr><td>timeoutInterval</td><td><code>15</code></td></tr><tr><td>retryPolicy</td><td><code>RetryPolicy(maxAttempts: 2)</code></td></tr><tr><td>errorLocalizer</td><td><code>AppNetworkErrorLocalizer()</code></td></tr></tbody></table></div></section><section class="feature-list" data-filter-container><section class="feature-card" data-search="GraphQL FetchCharacterProfileRequest /graphql id: String query: &quot;query Character($id: ID!) { character(id: $id) { name species status } }&quot; variables: [&quot;id&quot;: AnyEncodable(id)]"><div class="feature-heading"><div><p class="card-kicker">FEATURE</p><h2>GraphQL</h2></div><span class="count-badge">1 个端点</span></div><div class="table-wrap"><table><thead><tr><th>Method</th><th>Endpoint</th><th>Kind</th><th>Parameters</th><th>Request</th><th>Source</th></tr></thead><tbody><tr data-search="FetchCharacterProfileRequest /graphql id: String query: &quot;query Character($id: ID!) { character(id: $id) { name species status } }&quot; variables: [&quot;id&quot;: AnyEncodable(id)] DemoViewModel.swift"><td><span class="method method-post">POST</span></td><td><code>/graphql</code></td><td>GraphQL</td><td>id: String<br><div class="graphql-input"><span>query</span><pre><code>query Character($id: ID!) {
</style><style>.graphql-input{display:grid;gap:7px}.graphql-input>span{color:var(--muted);font:750 12px ui-monospace,SFMono-Regular,Menlo}.graphql-input pre{max-width:560px;margin:0;padding:13px 15px;overflow:auto;border:1px solid #29486f;border-radius:10px;background:#101924;white-space:pre}.graphql-input pre code{color:#dceaff;font-size:13px;line-height:1.55}</style></head><body><header class="hero compact"><div class="page-width"><a class="back" href="index.html">← 所有服务器</a><p class="eyebrow">BACKEND SERVER</p><h1>AppNetworkClient</h1><p class="lede"><code>https://rickandmortyapi.com</code> · 2 个端点</p></div></header><main class="page-width"><input class="search" type="search" placeholder="筛选 Feature、端点、参数、Request 或 Source…" aria-label="筛选端点" data-filter><section class="feature-card configuration"><div class="feature-heading"><div><p class="card-kicker">CLIENT CONFIGURATION</p><h2>配置</h2></div></div><div class="table-wrap"><table><thead><tr><th>配置项</th><th>值</th></tr></thead><tbody><tr><td>timeoutInterval</td><td><code>15</code></td></tr><tr><td>retryPolicy</td><td><code>RetryPolicy(maxAttempts: 2)</code></td></tr><tr><td>errorLocalizer</td><td><code>AppNetworkErrorLocalizer()</code></td></tr></tbody></table></div></section><section class="feature-list" data-filter-container><section class="feature-card" data-search="GraphQL FetchCharacterProfileRequest /graphql id: String query: query Character($id: ID!) {
character(id: $id) {
name
species
status
}
} variables: [&quot;id&quot;: AnyEncodable(id)]"><div class="feature-heading"><div><p class="card-kicker">FEATURE</p><h2>GraphQL</h2></div><span class="count-badge">1 个端点</span></div><div class="table-wrap"><table><thead><tr><th>Method</th><th>Endpoint</th><th>Kind</th><th>Parameters</th><th>Request</th><th>Source</th></tr></thead><tbody><tr data-search="FetchCharacterProfileRequest /graphql id: String query: query Character($id: ID!) {
character(id: $id) {
name
species
status
}
} variables: [&quot;id&quot;: AnyEncodable(id)] DemoViewModel.swift"><td><span class="method method-post">POST</span></td><td><code>/graphql</code></td><td>GraphQL</td><td>id: String<br><div class="graphql-input"><span>query</span><pre><code>query Character($id: ID!) {
character(id: $id) {
name
species
Expand Down
16 changes: 14 additions & 2 deletions Examples/NetworkingKitDemo/DemoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,18 @@ struct FetchCharacterProfileRequest: AppNetworkRequest, GraphQLRequest {
private let id: String

init(id: String) { self.id = id }
var query: String { "query Character($id: ID!) { character(id: $id) { name species status } }" }
var variables: [String: AnyEncodable]? { ["id": AnyEncodable(id)] }
var query: String {
"""
query Character($id: ID!) {
character(id: $id) {
name
species
status
}
}
"""
}
var variables: [String: AnyEncodable]? {
["id": AnyEncodable(id)]
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Or add the package manifest dependency:

```swift
dependencies: [
.package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.6")
.package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.7")
]
```

Expand Down
2 changes: 1 addition & 1 deletion README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ https://github.com/relaxfinger/NetworkingKit.git

```swift
dependencies: [
.package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.6")
.package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.4.7")
]
```

Expand Down
44 changes: 43 additions & 1 deletion Sources/BackendReferenceGenerator/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ enum BackendReferenceGenerator {
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 body = completeTypeBody(in: source, declaration: match) ?? capture(3, from: source, match: match)
let name = capture(1, from: source, match: match)
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)
Expand Down Expand Up @@ -188,6 +188,10 @@ enum BackendReferenceGenerator {
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] = []
let multilineQuery = try! NSRegularExpression(pattern: #"(?s)\bvar\s+query\s*:\s*String\s*\{\s*\"\"\"(.*?)\"\"\"\s*\}"#)
if let match = multilineQuery.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)), names.insert("query").inserted {
parameters.append("query: \(capture(1, from: body, match: match).trimmingCharacters(in: .whitespacesAndNewlines))")
}
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 }
Expand All @@ -196,12 +200,50 @@ enum BackendReferenceGenerator {
?? "<dynamic>"
parameters.append("\(name): \(expression.trimmingCharacters(in: .whitespaces))")
}
let multilineVariables = try! NSRegularExpression(pattern: #"(?s)\bvar\s+variables\s*:\s*[^\{\n]+\{\s*(\[.*?\])\s*\}"#)
if let match = multilineVariables.firstMatch(in: body, range: NSRange(body.startIndex..., in: body)), names.insert("variables").inserted {
parameters.append("variables: \(capture(1, from: body, match: match).trimmingCharacters(in: .whitespacesAndNewlines))")
}
if !names.contains("query"), body.range(of: #"\b(?:let|var)\s+query\b"#, options: .regularExpression) != nil {
parameters.append("query: <dynamic>")
}
return parameters
}

static func completeTypeBody(in source: String, declaration: NSTextCheckingResult) -> String? {
guard let declarationRange = Range(declaration.range, in: source), let openingBrace = source[declarationRange].firstIndex(of: "{") else { return nil }
var index = openingBrace
var depth = 0
var inString = false
var inMultilineString = false
var escaped = false

while index < source.endIndex {
if source[index...].hasPrefix("\"\"\"") {
inMultilineString.toggle()
index = source.index(index, offsetBy: 3)
continue
}
let character = source[index]
if inMultilineString {
index = source.index(after: index)
continue
}
if character == "\"" && !escaped { inString.toggle() }
if !inString {
if character == "{" { depth += 1 }
if character == "}" {
depth -= 1
if depth == 0 { return String(source[source.index(after: openingBrace)..<index]) }
}
}
escaped = character == "\\" && !escaped
if character != "\\" { escaped = false }
index = source.index(after: index)
}
return nil
}

static func parameterHTML(_ parameter: String) -> String {
if parameter.hasPrefix("query: ") {
let source = String(parameter.dropFirst("query: ".count))
Expand Down
14 changes: 13 additions & 1 deletion Tests/NetworkingKitTests/NetworkingKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ final class NetworkingKitTests: XCTestCase {
func testGraphQLResponseKeepsDataAndErrors() async throws {
let client = makeClient { request in
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json")
XCTAssertNotNil(request.httpBody)
let body = request.httpBody!
let payload = try! JSONSerialization.jsonObject(with: body) as! [String: Any]
let query = payload["query"] as! String
XCTAssertTrue(query.contains("query User {\n user {\n id\n name\n }\n}"))
let json = #"{"data":{"id":"42","name":"Ada"},"errors":[{"message":"partial result","path":["user",0]}]}"#
return (.init(url: try! XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: nil)!, json.data(using: .utf8)!)
}
Expand Down Expand Up @@ -486,7 +491,14 @@ private struct DeleteUserRequest: RestfulRequest {
private struct UserGraphQLRequest: GraphQLRequest {
typealias Response = GraphQLResponse<User>
let client: TestClient
let query = "query User { user { id name } }"
let query = """
query User {
user {
id
name
}
}
"""
}

private final class TestClient: NetworkClient, @unchecked Sendable {
Expand Down
Loading