12 个版本 (破坏性更新)

新功能 0.11.0 2024 年 8 月 21 日
0.10.1 2024 年 5 月 15 日
0.10.0 2024 年 2 月 6 日
0.9.0 2023 年 1 月 20 日
0.1.0 2020 年 6 月 25 日

#67 in HTTP 服务器

Download history 1180/week @ 2024-05-03 1202/week @ 2024-05-10 1134/week @ 2024-05-17 737/week @ 2024-05-24 879/week @ 2024-05-31 712/week @ 2024-06-07 1091/week @ 2024-06-14 1119/week @ 2024-06-21 776/week @ 2024-06-28 931/week @ 2024-07-05 740/week @ 2024-07-12 870/week @ 2024-07-19 1258/week @ 2024-07-26 836/week @ 2024-08-02 1046/week @ 2024-08-09 1190/week @ 2024-08-16

每月 4,662 次下载
用于 6 个 Crates

Apache-2.0

505KB
10K SLoC

Dropshot 是一个通用但具有特定观点的 crate,用于从 Rust 程序中暴露 REST API。它旨在简单轻量。它包括对 OpenAPI 的一等支持,以精确的规格形式直接从代码生成。这是因为它服务的 HTTP 资源的函数消耗特定类型的参数并返回特定类型的值,从而可以静态生成模式。

有关配置、设计和贡献的信息,请参阅 GitHub 仓库

有关 API 的使用信息,请参阅 文档


lib.rs:

Dropshot 是一个用于从 Rust 程序中暴露 REST API 的通用 crate。计划中的亮点包括

  • 适用于在基本不可信的网络上进行生产使用的适用性。基于 Dropshot 的系统应该是高性能的、可靠的、可调试的,并且能够抵御基本拒绝服务攻击(无论是有意的还是无意的)。

  • 一等 OpenAPI 支持,以精确的 OpenAPI 规范形式直接从代码生成。这是因为它服务的 HTTP 资源的函数消耗特定类型的参数并返回特定类型的值,从而可以静态生成模式。

  • 易于融入多元化的团队。Dropshot消费者的重要用例是拥有一个工程师团队,其中个人可以一次性向复杂的服务器添加几个端点,并且这样做应该是相对容易的。这意味着我们需要强调最小意外原则:就像Rust本身一样,我们可能会选择需要更多时间来学习的前期抽象,以便更难意外构建不会运行、在边缘情况下崩溃的系统等。

当我们提到“REST API”时,我们主要指的是基于现有HTTP原语构建的API,组织成分层资源,并提供了创建、更新、列出和删除这些资源的连续、幂等的机制。“REST”可以根据你与谁交谈而意味着不同的事情,有些人对什么是或不是RESTy非常教条。我们发现这种教条不仅无助于解决问题,而且定义不清。(考虑这样一个简单的情况,即尝试在REST API中更新资源。流行的API有时使用PUTPATCHPOST作为动词;以及JSON Merge Patch或JSON Patch作为格式。(有时甚至不知道自己在做什么!)几乎没有任何明确的标准,但这对于任何REST API来说都是一个真正的基本操作。)

有关考虑的替代crate的讨论,请参阅Oxide RFD 10

我们希望Dropshot相当通用,但它的主要目的是满足Oxide控制平面的需求。

用法

最基本的情况可能如下所示

use dropshot::ApiDescription;
use dropshot::ConfigDropshot;
use dropshot::ConfigLogging;
use dropshot::ConfigLoggingLevel;
use dropshot::HandlerTaskMode;
use dropshot::HttpServerStarter;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), String> {
    // Set up a logger.
    let log =
        ConfigLogging::StderrTerminal {
            level: ConfigLoggingLevel::Info,
        }
        .to_logger("minimal-example")
        .map_err(|e| e.to_string())?;

    // Describe the API.
    let api = ApiDescription::new();
    // Register API functions -- see detailed example or ApiDescription docs.

    // Start the server.
    let server =
        HttpServerStarter::new(
            &ConfigDropshot {
                bind_address: "127.0.0.1:0".parse().unwrap(),
                request_body_max_bytes: 1024,
                default_handler_task_mode: HandlerTaskMode::Detached,
                log_headers: Default::default(),
            },
            api,
            Arc::new(()),
            &log,
        )
        .map_err(|error| format!("failed to start server: {}", error))?
        .start();

    server.await
}

此服务器对所有资源返回404,因为没有注册API函数。有关使用共享状态提供一些资源的简单、文档化示例,请参阅examples/basic.rs

API处理函数

HTTP讨论的是资源。对于REST API,我们经常讨论端点操作,它们由HTTP方法和URI路径的组合来识别。

名为“项目”的资源的一些示例端点可能包括

  • GET /projects(列出项目)
  • POST /projects(创建项目的一种方式)
  • GET /projects/my_project(获取一个项目)
  • PUT /projects/my_project(更新(或可能创建)一个项目)
  • DELETE /projects/my_project(删除一个项目)

在Dropshot中,对给定API端点的请求由特定的Rust函数处理。该函数被称为入口点端点处理程序处理函数。当你设置Dropshot服务器时,你通过设置一个ApiDescription来配置可用的API端点以及哪个函数将处理每一个。

定义一组端点有两种方式

作为自由函数

最简单的Dropshot服务器将端点定义为函数,用endpoint宏进行注释。以下是一个列出硬编码项目的单个端点的示例

use dropshot::endpoint;
use dropshot::ApiDescription;
use dropshot::HttpError;
use dropshot::HttpResponseOk;
use dropshot::RequestContext;
use http::Method;
use schemars::JsonSchema;
use serde::Serialize;
use std::sync::Arc;

/// Represents a project in our API.
#[derive(Serialize, JsonSchema)]
struct Project {
    /// Name of the project.
    name: String,
}

/// Fetch a project.
#[endpoint {
    method = GET,
    path = "/projects/project1",
}]
async fn myapi_projects_get_project(
    rqctx: RequestContext<()>,
) -> Result<HttpResponseOk<Project>, HttpError>
{
   let project = Project { name: String::from("project1") };
   Ok(HttpResponseOk(project))
}

fn main() {
    let mut api = ApiDescription::new();

    // Register our endpoint and its handler function.  The "endpoint" macro
    // specifies the HTTP method and URI path that identify the endpoint,
    // allowing this metadata to live right alongside the handler function.
    api.register(myapi_projects_get_project).unwrap();

    // ... (use `api` to set up an `HttpServer` )
}

这里有很多事情要处理

  • endpoint宏指定了HTTP方法和URI路径。当我们调用ApiDescription::register()时,这些信息被用来注册我们的函数将处理的端点。
  • 我们函数的签名表明,在成功的情况下,它返回一个HttpResponseOk<Project>。这意味着该函数将返回一个HTTP 200状态码("OK")和一个类型为Project的对象。
  • 函数本身有一个Rustdoc注释,它将被用来在OpenAPI模式中记录这个端点

根据这些信息,Dropshot可以生成一个描述此API的OpenAPI规范,该规范描述了端点(OpenAPI称为"操作"),其文档,它可以返回的可能响应,以及每种响应类型的模式(这也可以包括文档)。这主要是静态的,但在运行时生成。

作为API特质

API特质是一个Rust特质,它代表了一组API端点。每个端点被定义为特质上的一个静态方法,整个特质用#[dropshot::api_description]进行注解。(需要Rust 1.75或更高版本。)

与基于函数的服务器相比,API特质将接口定义与实现分离。将定义和实现保存在不同的crate中可以允许更快地迭代接口,并且简化了使用从接口的OpenAPI输出生成的客户端的多服务仓库。此外,API特质允许有多个实现,例如用于测试的模拟实现。

以下是一个与上述基于函数的服务器等效的API特质的示例

use dropshot::ApiDescription;
use dropshot::HttpError;
use dropshot::HttpResponseOk;
use dropshot::RequestContext;
use http::Method;
use schemars::JsonSchema;
use serde::Serialize;
use std::sync::Arc;

/// Represents a project in our API.
#[derive(Serialize, JsonSchema)]
struct Project {
    /// Name of the project.
    name: String,
}

/// Defines the trait that captures all the methods.
#[dropshot::api_description]
trait ProjectApi {
    /// The context type used within endpoints.
    type Context;

    /// Fetch a project.
    #[endpoint {
        method = GET,
        path = "/projects/project1",
    }]
    async fn myapi_projects_get_project(
        rqctx: RequestContext<Self::Context>,
    ) -> Result<HttpResponseOk<Project>, HttpError>;
}

// The `dropshot::api_description` macro generates a module called
// `project_api_mod`. This module has a method called `api_description`
// that, given an implementation of the trait, returns an `ApiDescription`.
// The `ApiDescription` can then be used to set up an `HttpServer`.

// --- The following code may be in another crate ---

/// An empty type to hold the project server context.
///
/// This type is never constructed, and is purely a way to name
/// the specific server impl.
enum ServerImpl {}

impl ProjectApi for ServerImpl {
    type Context = ();

    async fn myapi_projects_get_project(
        rqctx: RequestContext<Self::Context>,
    ) -> Result<HttpResponseOk<Project>, HttpError> {
        let project = Project { name: String::from("project1") };
        Ok(HttpResponseOk(project))
    }
}

fn main() {
    // The type of `api` is provided for clarity -- it is generally inferred.
    // "api" will automatically register all endpoints defined in the trait.
    let mut api: ApiDescription<()> =
        project_api_mod::api_description::<ServerImpl>().unwrap();

    // ... (use `api` to set up an `HttpServer` )
}

请参阅api-trait.rsapi-trait-alternate.rs以获取有效示例。

限制

目前,#[dropshot::api_description]宏仅在模块上下文中受支持,不在函数体内。这是一个Rust限制——有关更多详细信息,请参阅Rust问题#79260

选择函数和特质之间的区别

原型设计:如果您正在使用少量端点进行原型设计,函数提供了一种更简单的方法来开始。特质的不利之处在于,端点签名至少定义了两次,一次在特质中,一次在实现中。

小型服务:对于相对隔离且编译快速的服务,特性和函数都是不错的选择。

具有多个实现的API:对于足够大以至于有第二个更简单的实现(可能只是部分)的服务,特质是最佳选择。

#[endpoint { ... }]属性参数

endpoint属性接受影响端点操作的参数以及出现在OpenAPI描述中的元数据。

#[endpoint {
    // Required fields
    method = { DELETE | HEAD | GET | OPTIONS | PATCH | POST | PUT },
    path = "/path/name/with/{named}/{variables}",

    // Optional fields
    tags = [ "all", "your", "OpenAPI", "tags" ],
}]

您在这里指定API端点的HTTP方法和路径(包括路径变量)。这些用于端点注册的一部分,并出现在OpenAPI规范输出中。

tags字段用于对API端点进行分类,并且仅影响OpenAPI规范输出。

函数参数

通常,处理函数看起来像这样

async fn f(
     rqctx: RequestContext<Context>,
     [query_params: Query<Q>,]
     [path_params: Path<P>,]
     [body_param: TypedBody<J>,]
     [body_param: UntypedBody,]
     [body_param: StreamingBody,]
     [raw_request: RawRequest,]
) -> Result<HttpResponse*, HttpError>

RequestContext必须首先出现。类型Context是调用者提供的上下文,它在创建服务器时提供。

类型 QueryPathTypedBodyUntypedBodyRawRequest 被称为 提取器,因为它们会将信息从请求中提取出来,使其可用于处理函数。

  • Query<Q> 从查询字符串中提取参数,将其反序列化为类型 Q 的实例。类型 Q 必须实现 serde::Deserializeschemars::JsonSchema
  • Path<P> 从 HTTP 路径中提取参数,将其反序列化为类型 P 的实例。类型 P 必须实现 serde::Deserializeschemars::JsonSchema
  • TypedBody<J> 通过将请求体解析为 JSON(或表单/url 编码)并反序列化为类型 J 的实例来提取请求体内容。类型 J 必须实现 serde::Deserializeschemars::JsonSchema
  • UntypedBody 提取请求体的原始字节。
  • StreamingBody 将请求体的原始字节作为 StreamBytes 块提供。
  • RawRequest 提供对底层 hyper::Request 的访问。希望这通常不需要。它对于实现 Dropshot 不提供的功能可能很有用。

QueryPath 实现 SharedExtractorTypedBodyUntypedBodyStreamingBodyRawRequest 实现 ExclusiveExtractor。您的函数可以接受 0-3 个提取器,但只能有一个是 ExclusiveExtractor,并且它必须是最后一个。否则,提取器参数的顺序无关紧要。

如果处理程序接受任何提取器且相应的提取无法完成,则请求将失败,状态码为 400,并显示错误消息(通常是一个验证错误)。

与任何 serde 可反序列化的类型一样,您可以通过将类型的对应属性设置为 Option 来使字段可选。以下是一个端点示例,该端点通过查询参数接收两个参数:“limit”,一个必需的 u32,和“marker”,一个可选的字符串

use http::StatusCode;
use dropshot::HttpError;
use dropshot::TypedBody;
use dropshot::Query;
use dropshot::RequestContext;
use hyper::Body;
use hyper::Response;
use schemars::JsonSchema;
use serde::Deserialize;
use std::sync::Arc;

#[derive(Deserialize, JsonSchema)]
struct MyQueryArgs {
    limit: u32,
    marker: Option<String>
}

struct MyContext {}

async fn myapi_projects_get(
    rqctx: RequestContext<MyContext>,
    query: Query<MyQueryArgs>)
    -> Result<Response<Body>, HttpError>
{
    let query_args = query.into_inner();
    let context: &MyContext = rqctx.context();
    let limit: u32 = query_args.limit;
    let marker: Option<String> = query_args.marker;
    Ok(Response::builder()
        .status(StatusCode::OK)
        .body(format!("limit = {}, marker = {:?}\n", limit, marker).into())?)
}

端点函数返回类型

端点处理函数是异步的,因此它们始终返回一个 Future。当我们下面提到“返回类型”时,我们将其用作未来的输出的简称。

端点函数必须返回一个实现了 HttpResponse 类型的值。通常情况下,这应该是一个实现了 HttpTypedResponse 类型(无论是 Dropshot 提供的其中一种,还是你自己创建的一种)的类型。

处理函数返回的类型越具体,构建时可以验证的内容就越多,从源代码中生成的 OpenAPI 架构也就越具体。例如,对端点 "/projects" 的 POST 请求可能会返回 Result<HttpResponseCreated<Project>, HttpError>。正如您所预期的,在成功的情况下,这将转换为 HTTP 201 "已创建" 响应,其主体由序列化的 Project 构建。在这个例子中,OpenAPI 工具可以在构建时识别出该函数在成功时生成一个 201 "已创建" 响应,其主体与 Project 的架构匹配(我们之前提到它实现了 Serialize),并且在运行时无法违反此合约。

以下是与 HTTP 方法相关联的 HTTP 响应代码的 HttpTypedResponse 的实现

返回类型 HTTP 状态码
HttpResponseOk 200
HttpResponseCreated 201
HttpResponseAccepted 202
HttpResponseDeleted 204
HttpResponseUpdatedNoContent 204

在响应架构不固定的情况下,端点应返回 Response<Body>,它也实现了 HttpResponse。请注意,在这种情况下,OpenAPI 规范将不包括任何状态码或类型信息。

那么,所有请求都运行的通用处理程序怎么办呢?

在 Dropshot 中没有这样的机制。相反,建议用户使用常规的 Rust 函数来通用化代码,并调用它们。有关更多信息,请参阅 README 中的设计笔记。

生成 OpenAPI 文档

对于给定的 ApiDescription,您还可以打印出描述 API 的 OpenAPI 文档。有关详细信息,请参阅 ApiDescription::openapi

使用 API 特性,#[dropshot::api_description] 宏生成一个名为 stub_api_description 的辅助函数,该函数返回一个不依赖实现的 ApiDescription。这个 存根描述 可以用来为特质生成 OpenAPI 文档,而无需实现该特质。例如

#
/// This is the API trait defined above.
#[dropshot::api_description]
trait ProjectApi {
    type Context;
    #[endpoint {
        method = GET,
        path = "/projects/project1",
    }]
    async fn myapi_projects_get_project(
        rqctx: RequestContext<Self::Context>,
    ) -> Result<HttpResponseOk<Project>, HttpError>;
}

let description = project_api_mod::stub_api_description().unwrap();
let mut openapi = description.openapi("Project Server", "1.0.0");
openapi.write(&mut std::io::stdout().lock()).unwrap();

存根描述不能用于实际的服务器:所有请求处理程序将立即引发恐慌。

对分页资源的支持

在这里,“分页”指的是一种接口模式,其中提供集合中项目列表的 HTTP 资源(或 API 端点)每次请求返回的项目数量相对较小,通常称为“结果页”。每一页都包含一些元数据,客户端可以使用这些元数据来请求下一页的结果。客户端可以重复此操作,直到获取所有结果。限制每次请求返回的结果数量有助于限制资源利用率和任何请求所需的时间,从而有利于横向扩展、高可用性和防止某些拒绝服务攻击(故意或非故意)。有关更多背景信息,请参阅 dropshot/src/pagination.rs 中的注释。

Dropshot 中的分页支持实现了这种常见模式

  • 此服务器公开了一个 API 端点,该端点返回包含在 集合 中的
  • 客户端不允许在一次请求中列出整个集合。相反,它们通过向一个端点发送一系列请求来列出集合。我们称这一系列请求为集合的扫描,有时也说客户端正在通过集合的分页
  • 扫描中的初始请求可以指定扫描参数,这些参数通常指定结果如何排序(即按哪些字段以及是否是升序或降序排序),任何要应用过滤器等。
  • 每次请求返回一次的结果页,以及与下一个请求一起提供的作为查询参数的分页令牌
  • 在同一扫描中的请求之间,扫描参数不能更改。
  • 对于所有请求:都有一个默认限制(例如,一次返回100个项目)。客户端可以使用查询参数请求更高的限制(例如,limit=1000)。此限制受服务器上的硬限制限制。如果客户端请求超过硬限制,服务器可以使用硬限制或拒绝请求。

例如,假设我们有一个名为 /animals 的API端点。每次返回的每个项都是一个Animal对象,可能看起来像这样

{
    "name": "aardvark",
    "class": "mammal",
    "max_weight": "80", /* kilograms, typical */
}

已知至少有150万种动物物种——太多了,无法在一个API调用中返回!我们的API支持通过name分页,我们将其称为数据集中的唯一字段。

对API的第一次请求获取/animals(没有查询字符串参数)并返回

{
    "next_page": "abc123...",
    "items": [
        {
            "name": "aardvark",
            "class": "mammal",
            "max_weight": "80",
        },
        ...
        {
            "name": "badger",
            "class": "mammal",
            "max_weight": "12",
        }
    ]
}

对API的后续请求获取/animals?page_token=abc123...。分页令牌abc123...对客户端来说是透明的,但通常编码了扫描参数和最后看到的项的值(例如name=badger)。客户端知道它完成了扫描,当它收到没有next_page的响应时。

我们的API端点还可以支持反向扫描。在这种情况下,当客户端发出第一次请求时,它应该获取/animals?sort=name-descending。现在第一个结果可能是zebra。同样,分页令牌必须包含扫描参数,以便在后续请求中,API端点知道我们正在从给定的值反向而不是正向扫描。在扫描过程中不允许更改方向或排序顺序。(您始终可以开始新的扫描,但不能从上一个扫描的位置继续。)

还可能支持按多个字段排序。例如,我们可以支持 sort=class-name,这意味着我们将首先按动物的类别排序,然后按名称排序。因此,我们将按顺序获得所有两栖动物,然后是所有哺乳动物,然后是所有爬行动物。主要要求是用于分页的字段组合必须是唯一的。我们不能仅按动物的类别进行分页。(原因如下:有超过6,000种哺乳动物。如果页面大小为,比如,1000,那么页面令牌将表示 "mammal",但这不足以看到我们在哺乳动物列表中的位置。无论是否有2种或6,000种哺乳动物都没有关系,因为如果客户端想要的话,可以将页面大小限制为仅一个项目,并且应该可以正常工作。)

Dropshot分页接口

分页接口包括

  • 输入:您的分页API端点处理函数应接受一个类型为 Query<PaginationParams<ScanParams, PageSelector>> 的参数,其中您定义 ScanParamsPageSelector(有关更多信息,请参阅 PaginationParams)。

  • 输出:您的分页API端点处理函数可以返回 Result<HttpResponseOk<ResultsPage<T>, HttpError>,其中 T: Serialize 是端点列出的项。您还可以使用自己的包含 ResultsPage 的结构(可能使用 #[serde(flatten)]),如果您需要这种行为。

有关如何使用这些内容的更多信息,请参阅“examples”目录中的完整、已记录的分页示例。

高级使用说明

通过使用 Query 在您的API端点处理函数中接受两个不同参数,可以接受除了分页参数以外的额外查询参数

use dropshot::HttpError;
use dropshot::HttpResponseOk;
use dropshot::PaginationParams;
use dropshot::Query;
use dropshot::RequestContext;
use dropshot::ResultsPage;
use dropshot::endpoint;
use schemars::JsonSchema;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Deserialize, JsonSchema)]
struct MyExtraQueryParams {
    do_extra_stuff: bool,
}

#[endpoint {
    method = GET,
    path = "/list_stuff"
}]
async fn my_list_api(
    rqctx: RequestContext<()>,
    pag_params: Query<PaginationParams<MyScanParams, MyPageSelector>>,
    extra_params: Query<MyExtraQueryParams>,
) -> Result<HttpResponseOk<ResultsPage<String>>, HttpError>
{
 # unimplemented!();
 /* ... */
}

您可能会期望这样做,而不是定义自己的结构,该结构包含一个 PaginationParams 并使用 #[serde(flatten)],这应该可以工作,但目前由于 serde_urlencoded#33(实际上是 serde#1183),这并不工作。

请注意,由 MyScanParams 定义的任何参数实际上都编码到了页面令牌中,当指定 page_token 时不需要提供。这并不是由 MyExtraQueryParams 定义的必需参数的情况--那些必须在每次调用时提供。

OpenAPI扩展

在生成的OpenAPI文档中,Dropshot向分页操作添加了x-dropshot-pagination扩展。当前值是一个具有以下格式的结构

{
    "required": [ .. ]
}

required数组中的字符串值是那些如果未指定page_token(当获取第一页数据时)则为必需的查询参数的名称。

DTrace探针

Dropshot可选地公开了两个DTrace探针,request_startrequest_finish。这些提供了关于每个请求的详细信息,例如它们的ID、本地和远程IP以及响应信息。有关完整列表,请参阅dropshot::dtrace::RequestInfodropshot::dtrace::ResponseInfo类型。

这些探针是通过usdt存储库实现的。如果在Rust版本1.66之前构建在macOS上,可能需要夜间工具链。否则,需要一个稳定编译器>= v1.59才能提供必要的功能。鉴于这些限制,USDT功能位于功能标志"usdt-probes"之后,这可能在未来版本中成为此存储库的默认功能。

重要:探针通过DTrace内核模块内部注册,可以通过dtrace(1M)可见。这是在创建HttpServer对象时完成的,但注册可能会失败。注册结果存储在创建后的服务器中,可以通过[HttpServer::probe_registration()]方法访问。这允许调用者决定如何处理失败,但确保如果可能,探针始终启用。

一旦部署,就可以通过DTrace看到探针。例如,运行

$ cargo +nightly run --example basic --features usdt-probes

并使用curl向它发送几个请求,我们可以通过以下调用看到DTrace探针

## dtrace -Zq -n 'dropshot*:::request-* { printf("%s\n", copyinstr(arg0)); }'
{"ok":{"id":"b793c62e-60e4-45c5-9274-198a04d9abb1","local_addr":"127.0.0.1:61028","remote_addr":"127.0.0.1:34286","method":"GET","path":"/counter","query":null}}
{"ok":{"id":"b793c62e-60e4-45c5-9274-198a04d9abb1","local_addr":"127.0.0.1:61028","remote_addr":"127.0.0.1:34286","status_code":200,"message":""}}
{"ok":{"id":"9050e30a-1ce3-4d6f-be1c-69a11c618800","local_addr":"127.0.0.1:61028","remote_addr":"127.0.0.1:41101","method":"PUT","path":"/counter","query":null}}
{"ok":{"id":"9050e30a-1ce3-4d6f-be1c-69a11c618800","local_addr":"127.0.0.1:61028","remote_addr":"127.0.0.1:41101","status_code":400,"message":"do not like the number 10"}}
{"ok":{"id":"a53696af-543d-452f-81b6-5a045dd9921d","local_addr":"127.0.0.1:61028","remote_addr":"127.0.0.1:57376","method":"PUT","path":"/counter","query":null}}
{"ok":{"id":"a53696af-543d-452f-81b6-5a045dd9921d","local_addr":"127.0.0.1:61028","remote_addr":"127.0.0.1:57376","status_code":204,"message":""}}

依赖项

~20–51MB
~1M SLoC