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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions Examples/NetworkingKitDemo/DemoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -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

Expand All @@ -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<GraphQLCharacterPayload>
private let id: String

Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
```

Expand Down Expand Up @@ -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
}
Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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<UserProfile>
var query: String {
"""
Expand Down
17 changes: 9 additions & 8 deletions README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
```

Expand Down Expand Up @@ -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 请求

Expand All @@ -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 }
Expand All @@ -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 }
Expand All @@ -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<UserProfile>
var query: String { "query { user { id name } }" }
}
Expand Down
11 changes: 6 additions & 5 deletions Tests/NetworkingKitTests/AppLayerExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import XCTest
@testable import NetworkingKit

final class AppLayerExampleTests: XCTestCase {
func testClassBasedAppRequestPatternBuildsRESTAndGraphQLRequests() throws {
func testProtocolBasedAppRequestPatternBuildsRESTAndGraphQLRequests() throws {
assertAccountClient(GetUserRequest())
assertAccountClient(FetchUserProfileRequest())

Expand All @@ -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 }
Expand All @@ -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<UserProfile>
var query: String { "query { user { id } }" }
}
Loading