Zeek.Http 10.0.29

Zeek.Http

Zeek.Http 是面向 Zeek 桌面应用的薄 HTTP client 基础设施模块。

它基于 .NET IHttpClientFactory,提供 endpoint 配置、named/typed HttpClient 注册、显式通用 header、显式授权 handler、HTTP 错误分类、AOT 友好的 JSON helper 和基础 stream 上传下载 helper。

它不是 REST client 平台,不绑定 Refit,不提供服务端 Host,不生成接口代理,也不接管 token 生命周期。

安装

发布包消费时引用:

<ItemGroup>
  <PackageReference Include="Zeek.Http" />
</ItemGroup>

当前仓库源码 sample 在模块未发布阶段使用本地 ProjectReference;正式业务项目应使用发布包。

源码结构

模块源码按能力边界分目录,但 public API namespace 不变:

Extensions/      # ModuleContext / IServiceCollection 注册入口
Building/        # HTTP API builder
Configuration/   # endpoint options
Authorization/   # 授权 provider、通用 header provider、匿名 request option
Errors/          # 错误分类、错误模型和异常
Json/            # 显式 JsonTypeInfo<T> helper
Transfer/        # 基础 stream 上传下载
Internal/        # handler、options reader、默认实现

引入模块

业务模块通过依赖 HttpModule 接入:

using Zeek.Core;
using Zeek.Http;

public sealed class AppModule : IZeekModule
{
    public static IReadOnlyList<ModuleDependency> Dependencies =>
        [ModuleDependency.Of<HttpModule>()];
}

HttpModule 只在 Build 阶段注册服务,不会在启动阶段访问网络。

配置 endpoint

配置文件只描述 HTTP endpoint 的 BaseAddressTimeout

{
  "Http": {
    "Clients": {
      "Default": {
        "BaseAddress": "https://api.example.com/",
        "Timeout": "00:00:30"
      },
      "ReportApi": {
        "BaseAddress": "https://report.example.com/",
        "Timeout": "00:01:00"
      }
    }
  }
}

规则:

  1. Default 是默认 endpoint 名称。
  2. named endpoint 不继承 Default
  3. 每个 endpoint 都必须配置自己的 BaseAddress
  4. token、secret、api key、授权开关不放入 Http:Clients
  5. 是否添加授权由注册代码显式表达。

注册 HttpClient

默认 endpoint:

context.AddZeekHttpClient();

named endpoint:

context.AddZeekHttpClient("ReportApi");

typed client:

context.AddZeekHttpClient<IOrderApi, OrderApiClient>()
    .AddZeekHttpHeaders(ZeekHttpOptions.DefaultEndpointName)
    .AddZeekHttpAuthorization<OrderAuthorizationProvider>(ZeekHttpOptions.DefaultEndpointName);

AddZeekHttpClient(...) 返回原生 IHttpClientBuilder,可以继续接入 .NET handler、test handler、logging handler 或 resilience 扩展。

实现 IZeekHttpHeaderProvider

public sealed class AppHeaderProvider : IZeekHttpHeaderProvider
{
    public ValueTask ApplyAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken = default)
    {
        request.Headers.TryAddWithoutValidation("X-App-Client", "desktop");
        return ValueTask.CompletedTask;
    }
}

注册并显式接入:

context.Services.AddSingleton<IZeekHttpHeaderProvider, AppHeaderProvider>();

context.AddZeekHttpClient()
    .AddZeekHttpHeaders(ZeekHttpOptions.DefaultEndpointName);

未调用 AddZeekHttpHeaders(...) 时,即使存在 provider,也不会自动添加 header。

授权

实现 IZeekHttpAuthorizationProvider

public sealed class GtrAuthProvider(GtrAuthService authService)
    : IZeekHttpAuthorizationProvider
{
    public async ValueTask ApplyAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken = default)
    {
        string? accessToken = await authService.EnsureAccessTokenAsync(cancellationToken);
        if (!string.IsNullOrWhiteSpace(accessToken))
        {
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", accessToken);
        }
    }
}

应用应注册具体授权 provider,并给 API client 显式绑定 provider 类型:

context.Services.AddSingleton<GtrAuthService>();
context.Services.AddTransient<GtrAuthProvider>();

context.AddZeekHttpApi<GtrAuthProvider>(api => api
    .Add<IOrderApi, OrderApiClient>()
    .Add<ICustomerApi, CustomerApiClient>()
    .Add<IProductApi, ProductApiClient>());

TAuthorizationProvider 必须是具体 provider 类型;接口或抽象类型会在注册阶段失败。

多服务端应用应为不同系统保留独立 provider 类型,并在 client 上直接绑定:

context.Services.AddSingleton<OrderAuthorizationProvider>();
context.Services.AddSingleton<ReportAuthorizationProvider>();

context.AddZeekHttpApi<OrderAuthorizationProvider>("OrderApi", api => api
    .Add<IOrderApi, OrderApiClient>()
    .Add<ICustomerApi, CustomerApiClient>());

context.AddZeekHttpApi<ReportAuthorizationProvider>("ReportApi", api => api
    .Add<IReportApi, ReportApiClient>());

每个 provider 独立处理自己的 Bearer token、API key、OAuth token 或签名;不要把多个系统的鉴权集中到一个 provider 里按 endpoint switch。Zeek.Http 不内置 JWT、OAuth 或 API Key scheme DSL。

登录等少数匿名请求可在 HttpRequestMessage.Options 中设置 ZeekHttpRequestOptions.AllowAnonymous,以跳过当前 client 绑定的 provider。

权限验证闭环

Zeek.Http 的“授权”只发生在客户端请求发送前:provider 给请求写入 Authorization、签名、租户或业务 header。真正的账号是否有效、角色是否允许、数据权限是否通过,仍由服务端返回 200401403 或业务错误码来表达。

sequenceDiagram participant UI as "界面/ViewModel" participant Api as "typed HttpClient / Refit API" participant Header as "Zeek header handler" participant Auth as "Zeek authorization handler" participant Provider as "应用授权 provider" participant AuthService as "应用 Auth 服务" participant Server as "服务端 API" participant Classifier as "错误分类 / 业务处理" UI->>Api: "调用业务 API" Api->>Header: "进入 HttpClient 管线" Header->>Header: "写入通用 header" Header->>Auth: "继续发送请求" Auth->>Auth: "检查 AllowAnonymous" alt "匿名请求" Auth-->>Server: "不调用 provider,直接发送" else "受保护请求" Auth->>Provider: "ApplyAsync(request)" Provider->>AuthService: "读取或确保凭据可用" AuthService-->>Provider: "返回 token / credential" Provider-->>Auth: "写 Authorization 或业务 header" Auth->>Server: "发送带授权信息的请求" end Server-->>Classifier: "返回 200 / 401 / 403 / 5xx / 业务 envelope" Classifier-->>UI: "返回 DTO,或交给 UI 提示错误"

这里的边界很重要:

  1. AllowAnonymous 只跳过客户端授权 provider,不代表服务端允许匿名访问。
  2. IZeekHttpAuthorizationProvider 不应该弹窗、不应该跳转登录页,也不应该解释业务错误码。
  3. 401 表示服务端拒绝当前凭据;可以由应用自定义 handler 做 forced refresh + retry once。
  4. 403 表示服务端识别了身份但权限不足;通常不应该自动 refresh 重试。
  5. GTR 的 Bearer/JWT、ERP 的自定义 header、MES 的签名请求都应放在各自后端模块的 provider / AuthService 中。

授权更新和 JWT refresh

Zeek.Http / Zeek.Http.Refit 不把 JWT refresh 做成框架内置功能。推荐边界是:

  1. IZeekHttpAuthorizationProvider 只负责在请求发送前确保 access token 可用,并写入 Authorization header。
  2. 应用 Auth 模块负责 bearer token、ERP header credential、refresh token rotation、过期时间懒刷新、single-flight 并发合并、退出登录和 UI 跳转。
  3. 登录和 refresh API 使用整体匿名 client,例如 AddZeekRefitAnonymousClient<IGtrAuthApi>(...)
  4. 业务 API 使用应用封装后的 AddGtrApis(...)AddErpApis(...),内部再调用 AddZeekRefitApi<TAuthorizationProvider>(...)AddZeekHttpApi<TAuthorizationProvider>(...)
  5. 401 后 forced refresh + retry 一次应放在应用自定义 DelegatingHandler,通过返回的 IHttpClientBuilder 追加。
  6. { code, message, data }{ success, msg, result } 这类 response envelope 解包属于应用 API 模块;可以按文档示例用应用侧 response processor handler 处理,但不是 Zeek.Http 内置 public API。

推荐把细节封装成后端级入口:

context.AddGtrApis(refitSettings);
context.AddErpApis(refitSettings);

推荐默认流程是“过期时间驱动懒刷新 + 401 forced refresh 兜底”:

flowchart TD Request["业务请求"] Provider["AuthorizationProvider.ApplyAsync"] State["读取 AuthService 状态"] Login{"是否已登录"} Fresh{"token 是否仍在安全有效期"} Gate["single-flight gate"] Refresh["匿名 refresh/login API"] Save["保存新 token 状态"] Header["写 Authorization / credential header"] Send["发送请求"] Status{"响应状态"} Retry["forced refresh 并重试一次"] Success["DTO / data"] Error["业务错误 / UI 提示"] Request --> Provider --> State --> Login Login -->|否| Send Login -->|是| Fresh Fresh -->|是| Header --> Send Fresh -->|否| Gate --> Refresh --> Save --> Header Send --> Status Status -->|200| Success Status -->|401 且可重放| Retry --> Refresh Status -->|403/5xx/业务失败| Error

如果后端有统一响应包裹,例如 GTR 的 { code, message, data } 或 ERP 的 { success, msg, result },解包应放在应用 API 模块自己的 handler / processor 中:

flowchart LR Client["typed client / Refit"] Headers["通用 header"] Auth["授权 provider"] Retry["401 retry handler"] Processor["业务响应解包 handler"] Server["服务端"] Client --> Headers --> Auth --> Retry --> Processor --> Server Server --> Processor --> Retry --> Auth --> Headers --> Client

推荐规则:

  1. 登录和 refresh client 使用整体匿名 client,避免授权 provider 依赖登录 API 后形成 DI 或请求管线闭环。
  2. processor 对 401、403、5xx 这类 HTTP 非成功状态不要抢先吞掉,先让 retry / classifier 看见状态码。
  3. processor 只解释当前后端协议,不要把 GTR / ERP / 其它系统的 envelope 做成 Zeek.Http 公共 API。
  4. UI 提示由 ViewModel 或 App service 统一处理;handler 只返回结果或抛出可读异常。

响应解包操作

解包不是“反序列化 DTO 前多写一行代码”这么简单。它要先判断 HTTP 状态,再判断业务 envelope,再决定把 data / result 交给 typed client 或 Refit。

推荐顺序:

  1. HTTP 非成功状态直接保留,例如 401 交给 retry handler,403 交给错误分类或业务提示。
  2. HTTP 成功后读取 JSON envelope。
  3. 判断业务成功码,例如 GTR code == 100000,ERP success == true
  4. 业务失败时抛出后端自己的业务异常,异常里保留 codemessage 和必要 request 信息。
  5. 业务成功时只把 data / result 的 JSON 交给后续 DTO 反序列化。

typed HttpClient 可以直接在 API client 内显式解包:

public async Task<OrderDto> GetOrderAsync(
    long id,
    CancellationToken cancellationToken = default)
{
    using var response = await httpClient.GetAsync(
        $"api/orders/{id}",
        cancellationToken);

    if (!response.IsSuccessStatusCode)
    {
        throw await GtrApiException.FromHttpAsync(response, cancellationToken);
    }

    return await GtrEnvelopeReader.ReadDataAsync(
        response.Content,
        GtrJsonContext.Default.OrderDto,
        cancellationToken);
}

Refit 更适合把解包放在应用侧 DelegatingHandler 中,让 Refit interface 仍然返回业务 DTO:

public sealed class GtrEnvelopeHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        HttpResponseMessage response = await base.SendAsync(request, cancellationToken);

        if (!response.IsSuccessStatusCode)
        {
            return response;
        }

        string json = await response.Content.ReadAsStringAsync(cancellationToken);
        using JsonDocument document = JsonDocument.Parse(json);
        JsonElement root = document.RootElement;

        int code = root.GetProperty("code").GetInt32();
        string? message = root.TryGetProperty("message", out JsonElement messageElement)
            ? messageElement.GetString()
            : null;

        if (code != 100000)
        {
            throw new GtrApiException(code, message ?? "服务端返回业务失败。");
        }

        string dataJson = root.TryGetProperty("data", out JsonElement dataElement)
            ? dataElement.GetRawText()
            : "null";

        response.Content.Dispose();
        response.Content = new StringContent(
            dataJson,
            Encoding.UTF8,
            "application/json");

        return response;
    }
}

上面代码是应用侧模式示例,不是 Zeek.Http 内置类型。生产代码通常还要补充:

  1. 使用 source-generated JsonTypeInfo<T> 读取 envelope 或 DTO,避免 NativeAOT 下依赖反射 JSON。
  2. 跳过文件下载、stream、图片、空响应、health check 等非 envelope 接口。
  3. 保留 response header、trace id、request uri、业务错误码,方便 UI 提示和日志排查。
  4. 不在 handler 里弹窗;让 ViewModel / App service 捕获 GtrApiException 后统一提示。

完整的两种刷新方案、流程图、single-flight 伪代码、401 retry 伪代码、typed HttpClient 接线、response envelope 解包、request body 重放和生产检查清单见:

docs/topics/Zeek.Http 客户端授权与刷新指南.md

AOT JSON

Zeek.Http 推荐显式 JsonSerializerContext / JsonTypeInfo<T>

[JsonSerializable(typeof(OrderDto))]
public partial class AppJsonContext : JsonSerializerContext;
var order = await response.Content.ReadZeekJsonAsync(
    AppJsonContext.Default.OrderDto,
    cancellationToken);

不要把反射 JSON fallback 当作 NativeAOT 主路径。

错误分类

通过 IZeekHttpErrorClassifier 把通用 HTTP 失败映射到 ZeekHttpErrorKind

var error = errorClassifier.Classify(response);
if (error.Kind == ZeekHttpErrorKind.Unauthorized)
{
    // 由业务或 UI 决定是否跳转登录。
}

Zeek.Http 只做通用分类,不解释业务错误码,不自动弹窗,不自动重新登录。

上传下载

IZeekHttpTransferService 提供基础 stream 传输:

var client = httpClientFactory.CreateClient(ZeekHttpOptions.DefaultEndpointName);
await using var destination = File.Create(path);

await transferService.DownloadAsync(
    client,
    "files/readme",
    destination,
    progress,
    cancellationToken);

调用方仍拥有 stream 生命周期;模块不定义业务文件协议,也不提供下载管理器、上传队列、暂停恢复、传输历史、传输持久化或 UI 进度面板。长时间传输如果要出现在界面任务列表中,由应用层组合 Zeek.BackgroundTasks 或自己的 workflow。

与 Refit 对接

业务项目推荐引用可选适配包:

<ItemGroup>
  <PackageReference Include="Zeek.Http.Refit" />
</ItemGroup>

Zeek.Http.Refit 依赖 Refit;Zeek.Http 主包仍不依赖 Refit。

定义 Refit interface:

public interface IAuthApi
{
    [Post("/auth/login")]
    Task<LoginResponse> LoginAsync(
        [Body] LoginRequest request,
        CancellationToken cancellationToken = default);
}

public interface IOrderApi
{
    [Get("/orders/{id}")]
    Task<OrderDto> GetAsync(
        int id,
        CancellationToken cancellationToken = default);
}

业务模块依赖 Refit 适配模块并注册 Refit API:

public static IReadOnlyList<ModuleDependency> Dependencies =>
    [ModuleDependency.Of<RefitModule>()];

var refitSettings = CreateRefitSettings();

context.AddZeekRefitAnonymousClient<IAuthApi>(refitSettings);

context.AddZeekRefitApi<BearerAuthorizationProvider>(refitSettings, api => api
    .Add<IOrderApi>()
    .Add<ICustomerApi>()
    .Add<IProductApi>());

多系统时直接为每个 API client 绑定自己的 provider:

context.AddZeekRefitApi<IdentityAuthorizationProvider>("IdentityApi", refitSettings, api => api
    .Add<IIdentityApi>());

context.AddZeekRefitApi<OrderAuthorizationProvider>("OrderApi", refitSettings, api => api
    .Add<IOrderApi>()
    .Add<ICustomerApi>());

context.AddZeekRefitApi<ReportAuthorizationProvider>("ReportApi", refitSettings, api => api
    .Add<IReportApi>());

AddZeekRefitApi<TAuthorizationProvider>(...) 只是把多个 AddZeekRefitClient<TApi, TAuthorizationProvider>(...) 聚合到同一 endpoint 和同一 provider 下;每个 Refit interface 仍然显式注册,不扫描程序集。默认所有请求会尝试授权;受保护 client 中的少数匿名方法用 [AllowAnonymous] 标记,并映射到 ZeekHttpRequestOptions.AllowAnonymous。登录、刷新等整体匿名 API 优先使用 AddZeekRefitAnonymousClient<TApi>(...)

Refit NativeAOT 场景应显式配置 source-generated serializer;Zeek.Http.Refit 只负责 Zeek handler 接线,不承诺 Refit upstream warning-clean:

private static RefitSettings CreateRefitSettings()
{
    return new RefitSettings(
        new SystemTextJsonContentSerializer(
            new JsonSerializerOptions
            {
                TypeInfoResolver = AppJsonContext.Default,
            }));
}

边界:

  1. Zeek.Http.Refit 只提供 RefitModule 和 Refit 注册捷径。
  2. Zeek.Http 主包不依赖 Refit。
  3. Refit attribute、接口代理、ApiException 和序列化行为仍由 Refit 自己承担。
  4. 不扫描程序集,不自动发现 API interface,不生成注册代码。

完整示例:

src/Zeek.Http.Refit/README.md
samples/Zeek.HttpRefitSample/
samples/Zeek.HttpRefitSample.Server/
docs/topics/Zeek.Http 使用指南.md

AOT / Trim

  • AOT 支持等级:Required。
  • Trim 策略:显式 endpoint、typed client、header provider、authorization provider 和 JsonTypeInfo<T> 注册,不扫描程序集发现 API client。
  • 反射策略:不把反射 JSON、动态代理或运行时 client 生成作为主路径。

Sample

  • ../../samples/Zeek.HttpSample/
  • ../../samples/Zeek.HttpAuthSample/
  • ../../samples/Zeek.HttpRefitSample/
  • ../../samples/Zeek.HttpRefitAuthSample/

这些 sample 当前属于源码级落地模块验证路径,发布前可能仍通过本地 ProjectReference 消费源码项目。

完整文档

  • ../../docs/modules/Zeek.Http.md
  • ../../docs/topics/Zeek.Http 使用指南.md
  • ../../docs/topics/Zeek.Http 客户端授权与刷新指南.md

当前限制

  • Zeek.Http 已进入当前 BaGet 正式发布包主线,sample 消费状态以根目录 samples/README.md 为准。
  • Zeek.Http 不负责 token 保存、refresh、登录 UI、业务 envelope 平台、传输管理平台或 Refit 主路径。

Showing the top 20 packages that depend on Zeek.Http.

Packages Downloads
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
19
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
15
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
12
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
10
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
5
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
4
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
3
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
2
Zeek.Http.Refit
Refit integration helpers for Zeek.Http desktop applications.
1

Version Downloads Last updated
10.0.42 4 07/29/2026
10.0.41 15 07/22/2026
10.0.40 10 07/22/2026
10.0.39 19 07/19/2026
10.0.38 12 07/17/2026
10.0.37 1 07/16/2026
10.0.36 5 06/29/2026
10.0.35 1 06/29/2026
10.0.31 5 06/27/2026
10.0.30 2 06/27/2026
10.0.29 2 06/26/2026
10.0.28 2 06/26/2026
10.0.27 2 06/26/2026
10.0.26 4 06/25/2026
10.0.25 3 06/24/2026