流畅的Flurl.Http

https://flurl.dev/docs/testable-http/

注意:除了 URL 构建和解析之外的所有内容都需要安装Flurl.Http而不是基本的Flurl包。

考虑与 HTTP 服务交互的一种非常常见的方式是“我想构建一个 URL,然后调用它”。Flurl.Http 允许您非常简洁地表达:

using Flurl;
using Flurl.Http;

var result = await baseUrl.AppendPathSegment("endpoint").GetAsync();

上面的代码发送一个 HTTPGET请求并返回一个IFlurlResponse,您可以从中获取 , 等属性StatusCode,并通过和Headers等方法获取正文内容。GetStringAsyncGetJsonAsync<T>

但通常你只想直接跳到正文,而 Flurl 提供了多种快捷方式来做到这一点:

T poco = await "http://api.foo.com".GetJsonAsync<T>();
string text = await "http://site.com/readme.txt".GetStringAsync();
byte[] bytes = await "http://site.com/image.jpg".GetBytesAsync();
Stream stream = await "http://site.com/music.mp3".GetStreamAsync();

轻松下载文件:

// filename is optional here; it will default to the remote file name
var path = await "http://files.foo.com/image.jpg"
    .DownloadFileAsync("c:\\downloads", filename);

其他“阅读”动词:

var headResponse = await "http://api.foo.com".HeadAsync();
var optionsResponse = await "http://api.foo.com".OptionsAsync();

然后是“写”动词:

await "http://api.foo.com".PostJsonAsync(new { a = 1, b = 2 });
await "http://api.foo.com/1".PatchJsonAsync(new { c = 3 });
await "http://api.foo.com/2".PutStringAsync("hello");

上面的所有方法都返回一个Task<IFlurlResponse>. 您当然可能希望在响应正文中返回一些数据:

T poco = await url.PostAsync(content).ReceiveJson<T>();
string s = await url.PatchJsonAsync(partial).ReceiveString();

奇怪的动词或内容?使用一种较低级别的方法:

await url.PostAsync(content); // a System.Net.Http.HttpContent object
await url.SendJsonAsync(HttpMethod.Trace, data);
await url.SendAsync(
    new HttpMethod("CONNECT"),
    httpContent, // optional
    cancellationToken,  // optional
    HttpCompletionOption.ResponseHeaderRead);  // optional

设置请求头:

// one:
await url.WithHeader("Accept", "text/plain").GetJsonAsync();
// multiple:
await url.WithHeaders(new { Accept = "text/plain", User_Agent = "Flurl" }).GetJsonAsync();

在上面的第二个示例中,User_Agent将自动呈现为User-Agent标头名称。(连字符在标头名称中很常见,但在 C# 标识符中不允许使用;下划线则相反。)

指定超时:

await url.WithTimeout(10).DownloadFileAsync(); // 10 seconds
await url.WithTimeout(TimeSpan.FromMinutes(2)).DownloadFileAsync();

取消请求:

var cts = new CancellationTokenSource();
var task = url.GetAsync(cts.Token);
...
cts.Cancel();

使用基本身份验证进行身份验证

await url.WithBasicAuth("username", "password").GetJsonAsync();

OAuth 2.0 不记名令牌

await url.WithOAuthBearerToken("mytoken").GetJsonAsync();

模拟 HTML 表单发布:

await "http://site.com/login".PostUrlEncodedAsync(new { 
    user = "user", 
    pass = "pass"
});

或者一个multipart/form-data帖子:

var resp = await "http://api.com".PostMultipartAsync(mp => mp
    .AddString("name", "hello!")                // individual string
    .AddStringParts(new {a = 1, b = 2})         // multiple strings
    .AddFile("file1", path1)                    // local file path
    .AddFile("file2", stream, "foo.txt")        // file stream
    .AddJson("json", new { foo = "x" })         // json
    .AddUrlEncoded("urlEnc", new { bar = "y" }) // URL-encoded                      
    .Add(content));                             // any HttpContent

通过请求发送一些 cookie:

var resp = await "https://cookies.com"
    .WithCookie("name", "value")
    .WithCookies(new { cookie1 = "foo", cookie2 = "bar" })
    .GetAsync();

更好的是,从第一个请求中获取响应 cookie,然后让 Flurl 确定何时将它们发回(根据RFC 6265):

await "https://cookies.com/login".WithCookies(out var jar).PostUrlEncodedAsync(credentials);
await "https://cookies.com/a".WithCookies(jar).GetAsync();
await "https://cookies.com/b".WithCookies(jar).GetAsync();

或者避免所有这些WithCookies调用并使用CookieSession

using (var session = new CookieSession("https://cookies.com")) {
    // set any initial cookies on session.Cookies
    await session.Request("a").GetAsync();
    await session.Request("b").GetAsync();
    // read cookies at any point using session.Cookies
}

ACookieJar也可以显式创建/修改,这可能有助于重新水化持久化的 cookie:

var jar = new CookieJar()
    .AddOrUpdate("cookie1", "foo", "https://cookies.com") // you must specify the origin URL
    .AddOrUpdate("cookie2", "bar", "https://cookies.com");

await "https://cookies.com/a".WithCookies(jar).GetAsync();

CookieJarFlurl 相当于CookieContainer来自HttpClient堆栈,但有一个主要优势:它不绑定到HttpMessageHandler,因此您可以在单个HttClient/Handler实例上模拟多个 cookie“会话”。

最好用一个例子来解释 Flurl 的 URL 构建器:

using Flurl;

var url = "http://www.some-api.com"
    .AppendPathSegment("endpoint")
    .SetQueryParams(new {
        api_key = _config.GetValue<string>["SomeApiKey"],
        max_results = 20,
        q = "Don't worry, I'll get encoded!"
    })
    .SetFragment("after-hash");

上面的示例(以及本网站上的大部分示例)使用扩展方法 offString来隐式创建Url对象。如果您愿意,您可以明确地做完全相同的事情:

var url = new Url("http://www.some-api.com").AppendPathSegment(...

从 3.0 开始,所有可用的扩展方法String也可用于System.Uri. 在任何一种情况下,您都可以使用构建器方法并在单个流畅的调用链中转换回原始表示(使用ToString()或)。ToUri()

除了上面的对象表示法之外,SetQueryParams还接受键值对、元组或字典对象的任何集合。这些替代项对于不是有效 C# 标识符的参数名称特别有用。如果你想一个一个地设置它们,还有一个SetQueryParam(单数)。在任何情况下,这些方法都会覆盖任何先前设置的同名值,但您可以通过传递一个集合来设置多个同名值:

var url = "http://www.mysite.com".SetQueryParam("x", new[] { 1, 2, 3 });
Assert.AreEqual("http://www.mysite.com?x=1&x=2&x=3", url)

构建器方法及其重载是高度可发现的、直观的,并且始终可链接。还包括一些破坏性方法,例如RemoveQueryParamRemovePathSegmentResetToRoot

解析

除了构建 URL 之外,Flurl.Url还可以有效地分解现有的 URL:

var url = new Url("https://user:pass@www.mysite.com:1234/with/path?x=1&y=2#foo");
Assert.AreEqual("https", url.Scheme);
Assert.AreEqual("user:pass", url.UserInfo);
Assert.AreEqual("www.mysite.com", url.Host);
Assert.AreEqual(1234, url.Port);
Assert.AreEqual("user:pass@www.mysite.com:1234", url.Authority);
Assert.AreEqual("https://user:pass@www.mysite.com:1234", url.Root);
Assert.AreEqual("/with/path", url.Path);
Assert.AreEqual("x=1&y=2", url.Query);
Assert.AreEqual("foo", url.Fragment);

虽然与 的解析能力相似,但System.UriFlurl 的目标是更符合 RFC 3986,更符合实际提供的字符串,因此在以下方面有所不同:

  • Uri.Query包括?角色;Url.Query才不是。
  • Uri.Fragment包括#角色;Url.Fragment才不是。
  • Uri.AbsolutePath始终包含/主角;Url.Path仅当它实际存在于原始字符串中时才包含它,例如 for"http://foo.com"Url.Path一个空字符串。
  • Uri.Authority不包括用户信息(即user:pass@);Url.Authority做。
  • Uri.Port如果不存在则有默认值;Url.Port可以为空且不默认。
  • Uri不会尝试解析相对 URL;Url假定如果字符串不以 开头{scheme}://,则它以路径开头并相应地解析它。

Url.QueryParams是一种特殊的集合类型,它保持顺序并允许重复名称,但针对唯一名称的典型情况进行了优化:

var url = new Url("https://www.mysite.com?x=1&y=2&y=3");
Assert.AreEqual("1", url.QueryParams.FirstOrDefault("x"));
Assert.AreEqual(new[] { "2", "3" }, url.QueryParams.GetAll("y"));

可变性

AUrl实际上是一个隐式转换为字符串的可变构建器对象。如果您需要一个不可变的 URL,例如将基本 URL 作为类的成员变量,常见的模式是将其键入为String

public class MyServiceClass
{
    private readonly string _baseUrl;

    public Task CallServiceAsync(string endpoint, object data) {
        return _baseUrl
            .AppendPathSegment(endpoint)
            .PostAsync(data); // requires Flurl.Http package
    }
}

这里调用AppendPathSegment创建一个新Url对象。结果是它_baseUrl保持不变,并且与您将其声明为Url.

绕过Urlwhen needed 可变性质的另一种方法是使用以下Clone()方法:

var url2 = url1.Clone().AppendPathSegment("next");

在这里你得到了一个Url基于另一个的新对象,所以你可以在不改变原来的情况下修改它。

编码

Flurl 负责对 URL 中的字符进行编码,但对路径段采用与对查询字符串值不同的方法。假设查询字符串值是高度可变的(例如来自用户输入),而路径段往往更“固定”并且可能已经编码,在这种情况下您不想进行双重编码。以下是 Flurl 遵循的规则:

  • 查询字符串值是完全 URL 编码的。
  • 对于路径段,不对诸如和之类的保留字符进行编码。/%
  • 对于路径段,对空格等非法字符进行编码。
  • 对于路径段,?字符被编码,因为查询字符串得到特殊处理。

在某些情况下,您可能希望设置一个已知已编码的查询参数。SetQueryParam有可选isEncoded参数:

url.SetQueryParam("x", "don%27t%20touch%20me", true);

虽然空格字符的官方 URL 编码是,但在查询参数中%20很常见。+你可以告诉它用它的可选参数Url.ToString来做到这一点:encodeSpaceAsPlus

var url = "http://foo.com".SetQueryParam("x", "hi there");
Assert.AreEqual("http://foo.com?x=hi%20there", url.ToString());
Assert.AreEqual("http://foo.com?x=hi+there", url.ToString(true));

实用方法

Url还包含一些方便的静态方法,例如Combine,它基本上是 URL 的Path.Combine,确保各部分之间只有一个分隔符:

var url = Url.Combine(
    "http://foo.com/",
    "/too/", "/many/", "/slashes/",
    "too", "few?",
    "x=1", "y=2"
// result: "http://www.foo.com/too/many/slashes/too/few?x=1&y=2"

为了帮助您避免与 .NET 中的各种 URL 编码/解码方法相关的一些臭名昭著的怪癖,Flurl 提供了一些“无怪癖”的替代方法:

Url.Encode(string s, bool encodeSpaceAsPlus); // includes reserved characters like / and ?
Url.EncodeIllegalCharacters(string s, bool encodeSpaceAsPlus); // reserved characters aren't touched
Url.Decode(string s, bool interpretPlusAsSpace);

Flurl.Http 提供了一组测试功能,使孤立的 arrange-act-assert 风格测试变得非常简单。它的核心是HttpTest,它的创建将 Flurl 踢入测试模式,测试对象中的所有 HTTP 活动都会自动伪造和记录。

using Flurl.Http.Testing;

[Test]
public void Test_Some_Http_Calling_Method() {
    using (var httpTest = new HttpTest()) {
        // Flurl is now in test mode
        sut.CallThingThatUsesFlurlHttp(); // HTTP calls are faked!
    }
}

大多数单元测试框架都有一些设置/拆卸方法的概念,这些方法在每次测试之前/之后执行*。对于针对 HTTP 调用代码进行大量测试的类,您可能更喜欢这种方法:

private HttpTest _httpTest;

[SetUp]
public void CreateHttpTest() {
    _httpTest = new HttpTest();
}

[TearDown]
public void DisposeHttpTest() {
    _httpTest.Dispose();
}

[Test]
public void Test_Some_Http_Calling_Method() {
    // Flurl is in test mode
}

注意:由于用于在 SUT 中发出调用伪造信号的机制的已知问题HttpTest,从异步设置方法实例化将不起作用。

安排

默认情况下,假 HTTP 调用返回 200 (OK) 状态和空主体。当然,您可能希望针对其他响应测试您的代码。

httpTest.RespondWith("some response body");
sut.DoThing();

将对象用于 JSON 响应:

httpTest.RespondWithJson(new { x = 1, y = 2 });

测试失败条件:

httpTest.RespondWith("server error", 500);
httpTest.RespondWithJson(new { message = "unauthorized" }, 401);
httpTest.SimulateTimeout();

RespondWith*方法是可链接的:

httpTest
    .RespondWith("some response body")
    .RespondWithJson(someObject)
    .RespondWith("error!", 500);

sut.DoThingThatMakesSeveralHttpCalls();

在幕后,每个人都会RespondWith*向线程安全队列添加一个假响应。

从 3.0 开始,您还可以设置仅适用于符合特定条件的请求的响应。这个例子展示了所有的可能性:

httpTest
    .ForCallsTo("*.api.com*", "*.test-api.com*") // multiple allowed, wildcard supported
    .WithVerb("put", "PATCH") // or HttpMethod.Put, HttpMethod.Patch
    .WithQueryParam("x", "a*") // value optional, wildcard supported
    .WithQueryParams(new { y = 2, z = 3 })
    .WithAnyQueryParam("a", "b", "c")
    .WithoutQueryParam("d")
    .WithHeader("h1", "f*o") // value optional, wildcard supported
    .WithoutHeader("h2")
    .WithRequestBody("*something*") // wildcard supported
    .WithRequestJson(new { a = "*", b = "hi" }) // wildcard supported in sting values
    .With(call => true) // check anything on the FlurlCall
    .Without(call => false) // check anything on the FlurlCall
    .RespondWith("all conditions met!", 200);

在某些情况下需要进行真正的通话?

httpTest
    .ForCallsTo("https://api.thirdparty.com/*")
    .AllowRealHttp();

行动

Once an HttpTest is created and any specific responses are queued, simply call into a test subject. When the SUT makes an HTTP call with Flurl, the real call is effectively blocked and the next fake response is dequeued and returned instead. However, when only one response remains in the queue (matching any filter criteria, if provided), that response becomes "sticky", i.e. it is not dequeued and hence gets returned in all subsequent calls.

There is no need to mock or stub any Flurl objects in order for this to work. HttpTest uses the logical asynchronous call context to flow a signal through the SUT and notify Flurl to fake the call.

Assert

As HTTP calls are faked, they are automatically recorded to a call log, allowing you to assert that certain calls were made. Assertions are test framework-agnostic; they throw an exception at any point when a match is not found as specified, signaling a test failure in virtually all testing frameworks.

HttpTest provides a couple assertion methods against the call log:

sut.DoThing();

// were calls to specific URLs made?
httpTest.ShouldHaveCalled("http://some-api.com/*");
httpTest.ShouldNotHaveCalled("http://other-api.com/*");

// were any calls made?
httpTest.ShouldHaveMadeACall();
httpTest.ShouldNotHaveMadeACalled();

You can make further assertions against specific calls, fluently of course:

httpTest.ShouldHaveCalled("http://some-api.com/*")
    .WithQueryParam("x", "1*")
    .WithVerb(HttpMethod.Post)
    .WithContentType("application/json")
    .WithoutHeader("my-header-*")
    .WithRequestBody("{\"a\":*,\"b\":*}")
    .Times(3);

Times(n) allows you to assert that the call was made a specific number of times; otherwise, the assertion passes when one or more matching calls were made. In all cases where a name and value can be passed, a null value (the default) means ignore and just assert the name. And like with test setup criteria, the * wildcard is supported virtually everywhere.

当这些With*方法不能为您提供所需的一切时,您可以下一个级别并直接断言调用日志:

Assert.That(httpTest.CallLog.Any(call => /* assert anything about the call */));

CallLog是一个IList<FlurlCall>。一个对象包含许多此处FlurlCall指定的有用信息。

posted @ 2023-01-01 19:46  多见多闻  阅读(2184)  评论(0编辑  收藏  举报