From 7f74d3f8e617b05de5ac0f3dcc6388adb172e14d Mon Sep 17 00:00:00 2001 From: Jipeng Zhang Date: Sun, 19 Jul 2026 23:16:35 +0700 Subject: [PATCH] feat: add client-constrained app request protocols --- CHANGELOG.md | 6 ++++++ .../NetworkingKitDemo/DemoViewModel.swift | 11 ++++++----- README.md | 19 +++++++++---------- README.zh-Hans.md | 17 +++++++++-------- .../AppLayerExampleTests.swift | 11 ++++++----- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3d31b..07a1b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to NetworkingKit are documented in this file. +## 2.3.6 - 2026-07-19 + +### Changed + +- Replace App-layer request-base class examples with client-constrained request protocols that provide a default shared client and support both structures and classes. + ## 2.3.5 - 2026-07-19 ### Changed diff --git a/Examples/NetworkingKitDemo/DemoViewModel.swift b/Examples/NetworkingKitDemo/DemoViewModel.swift index 29c3afb..c461cbc 100644 --- a/Examples/NetworkingKitDemo/DemoViewModel.swift +++ b/Examples/NetworkingKitDemo/DemoViewModel.swift @@ -187,9 +187,10 @@ struct AppNetworkErrorLocalizer: NetworkErrorLocalizing { } } -/// The request base for endpoints served by `AppNetworkClient`. -class AppNetworkRequest: @unchecked Sendable { - typealias Client = AppNetworkClient +/// The request contract for endpoints served by `AppNetworkClient`. +protocol AppNetworkRequest: NetworkRequest where Client == AppNetworkClient {} + +extension AppNetworkRequest { var client: AppNetworkClient { .shared } } @@ -202,7 +203,7 @@ struct RESTCharacter: Codable, Sendable { let status: String } -final class GetCharacterRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendable { +struct GetCharacterRequest: AppNetworkRequest, RestfulRequest { typealias Response = RESTCharacter private let id: String @@ -221,7 +222,7 @@ struct GraphQLCharacterPayload: Codable, Sendable { let character: Character? } -final class FetchCharacterProfileRequest: AppNetworkRequest, GraphQLRequest, @unchecked Sendable { +struct FetchCharacterProfileRequest: AppNetworkRequest, GraphQLRequest { typealias Response = GraphQLResponse private let id: String diff --git a/README.md b/README.md index af08d8b..b0530ff 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Add the package in Xcode through **File > Add Package Dependencies**, or declare ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.4") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.6") ] ``` @@ -111,16 +111,15 @@ final class AppNetworkClient: SharedNetworkClient, @unchecked Sendable { `NetworkConfiguration` is immutable and scoped to one client. A request can still override `timeoutInterval` when a specific endpoint needs a different timeout. `makeEncoder()` and `makeDecoder()` create a fresh codec per operation, avoiding shared mutable codec configuration. Configure app-wide headers, authentication, request signing, logging, and metrics in `interceptors`; do not add them to `AppRequest`. -### 2. Add an app request base class +### 2. Add an app request protocol -Use a base class to avoid repeating the client in every request. Keep this base class free of `NetworkRequest` conformance so a REST or GraphQL subclass can receive the defaults from its own request protocol. Requests inheriting from a class must also be classes; Swift structures cannot inherit from classes. - -`NetworkRequest` binds both a concrete `Client` type and a `Response` type. `AppNetworkRequest` directly fixes `AppNetworkClient` without erasing it to `any NetworkClient`; the concrete REST or GraphQL request declares only its `Response`. This makes it impossible to accidentally use a request from one backend family with another backend's client. When an app has multiple backend clients, define one equivalent request base class per client. The base class should not own common headers, authentication, or logging because those responsibilities apply to every request and belong to `NetworkInterceptor`. +Use an app-level protocol to avoid repeating the client in every request. `NetworkRequest` binds both a concrete `Client` type and a `Response` type. `AppNetworkRequest` directly constrains `Client` to `AppNetworkClient` without erasing it to `any NetworkClient`; each REST or GraphQL request declares only its `Response`. This makes it impossible to accidentally use a request from one backend family with another backend's client. When an app has multiple backend clients, define one equivalent request protocol per client. This pattern works with both structures and classes. The protocol should not own common headers, authentication, or logging because those responsibilities apply to every request and belong to `NetworkInterceptor`. ```swift -class AppNetworkRequest: @unchecked Sendable { - typealias Client = AppNetworkClient +protocol AppNetworkRequest: NetworkRequest +where Client == AppNetworkClient {} +extension AppNetworkRequest { var client: AppNetworkClient { .shared } @@ -135,7 +134,7 @@ struct User: Decodable, Sendable { let name: String } -final class GetUserRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendable { +struct GetUserRequest: AppNetworkRequest, RestfulRequest { typealias Response = User var path: String { "/users/123" } var method: HTTPMethod { .get } @@ -151,7 +150,7 @@ For a JSON request body, return any `Encodable & Sendable` value from `body`. Th For successful endpoints with no response body, such as `204 No Content`, use `EmptyResponse` as the response type. ```swift -final class DeleteUserRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendable { +struct DeleteUserRequest: AppNetworkRequest, RestfulRequest { typealias Response = EmptyResponse var path: String { "/users/123" } var method: HTTPMethod { .delete } @@ -172,7 +171,7 @@ struct UserProfile: Decodable, Sendable { let email: String } -final class FetchUserProfileRequest: AppNetworkRequest, GraphQLRequest, @unchecked Sendable { +struct FetchUserProfileRequest: AppNetworkRequest, GraphQLRequest { typealias Response = GraphQLResponse var query: String { """ diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 4997106..62335d4 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -28,7 +28,7 @@ NetworkingKit 是一个面向 iOS 与 macOS App 的轻量级原生 Swift 网络 ```swift dependencies: [ - .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.4") + .package(url: "https://github.com/relaxfinger/NetworkingKit.git", from: "2.3.6") ] ``` @@ -107,19 +107,20 @@ final class AppNetworkClient: SharedNetworkClient, @unchecked Sendable { `NetworkConfiguration` 是不可变的 Client 级默认值;单个 Request 仍可覆盖 `timeoutInterval`。`makeEncoder()` 和 `makeDecoder()` 会为每次操作创建独立的编解码器,避免共享可变配置。通用 Header、认证、请求签名、日志和埋点应统一在 `interceptors` 中配置,不应放在 `AppRequest`。 -### 2. 创建 App Request 基类 +### 2. 创建 App Request 协议 ```swift -class AppNetworkRequest: @unchecked Sendable { - typealias Client = AppNetworkClient +protocol AppNetworkRequest: NetworkRequest +where Client == AppNetworkClient {} +extension AppNetworkRequest { var client: AppNetworkClient { .shared } } ``` -采用基类时,业务 Request 也必须是 class,因为 Swift 的 `struct` 不能继承 class。基类不应直接遵循 `NetworkRequest`,以便 REST 或 GraphQL 子类获得其各自协议提供的默认值。`NetworkRequest` 同时绑定具体的 `Client` 类型与 `Response` 类型;`AppNetworkRequest` 直接固定为 `AppNetworkClient`,但不使用 `any NetworkClient` 抹除它,具体 REST 或 GraphQL 请求只声明自身的 `Response`。这样能在编译期避免某个后端的 Request 被错误地绑定到另一个后端的 Client。有多个后端 Client 时,为每个 Client 定义一个等价的请求基类。`AppNetworkRequest` 不应承载通用 Header、认证或日志;这些跨请求职责属于 `NetworkInterceptor`。 +使用 App 级协议可以避免在每个 Request 中重复提供 Client。`NetworkRequest` 同时绑定具体的 `Client` 类型与 `Response` 类型;`AppNetworkRequest` 通过 `Client == AppNetworkClient` 直接约束 Client,但不使用 `any NetworkClient` 抹除它,具体 REST 或 GraphQL 请求只声明自身的 `Response`。这样能在编译期避免某个后端的 Request 被错误地绑定到另一个后端的 Client。有多个后端 Client 时,为每个 Client 定义一个等价的请求协议。这种写法同时支持 `struct` 与 class。`AppNetworkRequest` 不应承载通用 Header、认证或日志;这些跨请求职责属于 `NetworkInterceptor`。 ### 3. REST 请求 @@ -129,7 +130,7 @@ struct User: Decodable, Sendable { let name: String } -final class GetUserRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendable { +struct GetUserRequest: AppNetworkRequest, RestfulRequest { typealias Response = User var path: String { "/users/123" } var method: HTTPMethod { .get } @@ -144,7 +145,7 @@ final class GetUserRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendab 对于 `204 No Content` 等成功但无 body 的接口,请使用 `EmptyResponse` 作为响应类型: ```swift -final class DeleteUserRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendable { +struct DeleteUserRequest: AppNetworkRequest, RestfulRequest { typealias Response = EmptyResponse var path: String { "/users/123" } var method: HTTPMethod { .delete } @@ -162,7 +163,7 @@ struct UserProfile: Decodable, Sendable { let name: String } -final class FetchUserProfileRequest: AppNetworkRequest, GraphQLRequest, @unchecked Sendable { +struct FetchUserProfileRequest: AppNetworkRequest, GraphQLRequest { typealias Response = GraphQLResponse var query: String { "query { user { id name } }" } } diff --git a/Tests/NetworkingKitTests/AppLayerExampleTests.swift b/Tests/NetworkingKitTests/AppLayerExampleTests.swift index 8db9775..885a201 100644 --- a/Tests/NetworkingKitTests/AppLayerExampleTests.swift +++ b/Tests/NetworkingKitTests/AppLayerExampleTests.swift @@ -11,7 +11,7 @@ import XCTest @testable import NetworkingKit final class AppLayerExampleTests: XCTestCase { - func testClassBasedAppRequestPatternBuildsRESTAndGraphQLRequests() throws { + func testProtocolBasedAppRequestPatternBuildsRESTAndGraphQLRequests() throws { assertAccountClient(GetUserRequest()) assertAccountClient(FetchUserProfileRequest()) @@ -36,15 +36,16 @@ private final class AppNetworkClient: SharedNetworkClient, @unchecked Sendable { private init() {} } -private class AppNetworkRequest: @unchecked Sendable { - typealias Client = AppNetworkClient +private protocol AppNetworkRequest: NetworkRequest where Client == AppNetworkClient {} + +private extension AppNetworkRequest { var client: AppNetworkClient { .shared } } private struct User: Codable, Sendable { let id: String } private struct UserProfile: Codable, Sendable { let id: String } -private final class GetUserRequest: AppNetworkRequest, RestfulRequest, @unchecked Sendable { +private struct GetUserRequest: AppNetworkRequest, RestfulRequest { typealias Response = User var path: String { "/users/123" } var method: HTTPMethod { .get } @@ -53,7 +54,7 @@ private final class GetUserRequest: AppNetworkRequest, RestfulRequest, @unchecke var contentType: String? { nil } } -private final class FetchUserProfileRequest: AppNetworkRequest, GraphQLRequest, @unchecked Sendable { +private struct FetchUserProfileRequest: AppNetworkRequest, GraphQLRequest { typealias Response = GraphQLResponse var query: String { "query { user { id } }" } }