diff --git a/.github/workflows/codacy-analysis.yml b/.github/workflows/codacy-analysis.yml deleted file mode 100644 index 141000258..000000000 --- a/.github/workflows/codacy-analysis.yml +++ /dev/null @@ -1,54 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow checks out code, performs a Codacy security scan -# and integrates the results with the -# GitHub Advanced Security code scanning feature. For more information on -# the Codacy security scan action usage and parameters, see -# https://github.com/codacy/codacy-analysis-cli-action. -# For more information on Codacy Analysis CLI in general, see -# https://github.com/codacy/codacy-analysis-cli. - -name: Codacy Security Scan - -on: - push: - branches: [ dev ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ dev ] - schedule: - - cron: '23 10 * * 5' - -jobs: - codacy-security-scan: - name: Codacy Security Scan - runs-on: ubuntu-latest - steps: - # Checkout the repository to the GitHub Actions runner - - name: Checkout code - uses: actions/checkout@v2 - - # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis - - name: Run Codacy Analysis CLI - uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b - with: - # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository - # You can also omit the token and run the tools that support default configurations - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - verbose: true - output: results.sarif - format: sarif - # Adjust severity of non-security issues - gh-code-scanning-compat: true - # Force 0 exit code to allow SARIF file generation - # This will handover control about PR rejection to the GitHub side - max-allowed-issues: 2147483647 - - # Upload the SARIF file generated in the previous step - - name: Upload SARIF results file - uses: github/codeql-action/upload-sarif@v1 - with: - sarif_file: results.sarif diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index ec48e143f..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: CodeQL Analysis - -on: - push: - tags: - - '*' - branches: - - dev - pull_request: - workflow_dispatch: - schedule: - - cron: '0 8 * * *' - -jobs: - analyze: - name: codeql-analysis - runs-on: windows-latest - steps: - # Due to the insufficient memory allocated by default, CodeQL sometimes requires more to be manually allocated - - - name: Configure Pagefile - id: config_pagefile - uses: al-cheb/configure-pagefile-action@v1.2 - with: - minimum-size: 8GB - maximum-size: 32GB - disk-root: "D:" - - - name: Checkout repository - id: checkout_repo - uses: actions/checkout@v2.4.0 - - - name: Setup .NET - uses: actions/setup-dotnet@v1.9.0 - with: - dotnet-version: '6.0' - - - name: Initialize CodeQL - id: init_codeql - uses: github/codeql-action/init@v1 - with: - queries: security-and-quality - - - name: Build project - id: build_project - shell: pwsh - run: | - dotnet build ./src/RestSharp/RestSharp.csproj -c Release - - - name: Perform CodeQL Analysis - id: analyze_codeql - uses: github/codeql-action/analyze@v1 - -# Built with ❤ by [Pipeline Foundation](https://pipeline.foundation) \ No newline at end of file diff --git a/RestSharp.sln b/RestSharp.sln index 2b38a8b2f..829682f2d 100644 --- a/RestSharp.sln +++ b/RestSharp.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestSharp.Tests.Serializers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestSharp.Serializers.Xml", "src\RestSharp.Serializers.Xml\RestSharp.Serializers.Xml.csproj", "{4A35B1C5-520D-4267-BA70-2DCEAC0A5662}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestSharp.Tests.Legacy", "test\RestSharp.Tests.Legacy\RestSharp.Tests.Legacy.csproj", "{5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug.Appveyor|Any CPU = Debug.Appveyor|Any CPU @@ -348,6 +350,36 @@ Global {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x64.Build.0 = Release|Any CPU {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x86.ActiveCfg = Release|Any CPU {4A35B1C5-520D-4267-BA70-2DCEAC0A5662}.Release|x86.Build.0 = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|Any CPU.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|Any CPU.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|ARM.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|ARM.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|Mixed Platforms.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|Mixed Platforms.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|x64.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|x64.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|x86.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug.Appveyor|x86.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|ARM.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|ARM.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|x64.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Debug|x86.Build.0 = Debug|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|Any CPU.Build.0 = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|ARM.ActiveCfg = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|ARM.Build.0 = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|x64.ActiveCfg = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|x64.Build.0 = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|x86.ActiveCfg = Release|Any CPU + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -365,5 +397,6 @@ Global {6D7D1D60-4473-4C52-800C-9B892C6640A5} = {9051DDA0-E563-45D5-9504-085EBAACF469} {E6D94C12-9AD7-46E6-AB62-3676F25FDE51} = {9051DDA0-E563-45D5-9504-085EBAACF469} {4A35B1C5-520D-4267-BA70-2DCEAC0A5662} = {8C7B43EB-2F93-483C-B433-E28F9386AD67} + {5A8A5BBE-28DA-4C89-B393-BE39A96E8DC0} = {9051DDA0-E563-45D5-9504-085EBAACF469} EndGlobalSection EndGlobal diff --git a/docs/usage.md b/docs/usage.md index 01daa9b1f..35bbe005b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -360,6 +360,34 @@ var statusCode = client.PostJsonAsync("orders", request, cancellationToken); The same two extensions also exist for `PUT` requests (`PutJsonAsync`); +### JSON streaming APIs + +For HTTP API endpoints that stream the response data (like [Twitter search stream](https://developer.twitter.com/en/docs/twitter-api/tweets/filtered-stream/api-reference/get-tweets-search-stream)) you can use RestSharp with `StreamJsonAsync`, which returns an `IAsyncEnumerable`: + +```csharp +public async IAsyncEnumerable SearchStream( + [EnumeratorCancellation] CancellationToken cancellationToken = default +) { + var response = _client.StreamJsonAsync>( + "tweets/search/stream", cancellationToken + ); + + await foreach (var item in response.WithCancellation(cancellationToken)) { + yield return item.Data; + } +} +``` + +The main limitation of this function is that it expects each JSON object to be returned as a single line. It is unable to parse the response by combining multiple lines into a JSON string. + +### Downloading binary data + +There are two functions that allow you to download binary data from the remote API. + +First, there's `DownloadDataAsync`, which returns `Task`. This function allows you to open a stream reader and asynchronously stream large responses to memory or disk. + ## Blazor support Inside a Blazor webassembly app, you can make requests to external API endpoints. Microsoft examples show how to do it with `HttpClient`, and it's also possible to use RestSharp for the same purpose. diff --git a/src/RestSharp/.nvmrc b/src/RestSharp/.nvmrc deleted file mode 100644 index 9f2d0e647..000000000 --- a/src/RestSharp/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v17.0.1 diff --git a/src/RestSharp/Extensions/ResponseStatusExtensions.cs b/src/RestSharp/Extensions/ResponseStatusExtensions.cs deleted file mode 100644 index e6a8992b9..000000000 --- a/src/RestSharp/Extensions/ResponseStatusExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright © 2009-2021 John Sheehan, Andrew Young, Alexey Zimarev and RestSharp community -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System.Net; -using static System.Net.WebExceptionStatus; - -namespace RestSharp.Extensions; - -public static class ResponseStatusExtensions { - /// - /// Convert a to a instance. - /// - /// The response status. - /// - /// responseStatus - public static WebException ToWebException(this ResponseStatus responseStatus) - => responseStatus switch { - ResponseStatus.None => new WebException("The request could not be processed.", ServerProtocolViolation), - ResponseStatus.Error => new WebException("An error occurred while processing the request.", ServerProtocolViolation), - ResponseStatus.TimedOut => new WebException("The request timed-out.", WebExceptionStatus.Timeout), - ResponseStatus.Aborted => new WebException("The request was aborted.", WebExceptionStatus.Timeout), - _ => throw new ArgumentOutOfRangeException(nameof(responseStatus)) - }; -} \ No newline at end of file diff --git a/src/RestSharp/Request/InvalidRequestException.cs b/src/RestSharp/Request/InvalidRequestException.cs deleted file mode 100644 index 27b5bd805..000000000 --- a/src/RestSharp/Request/InvalidRequestException.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright © 2009-2021 John Sheehan, Andrew Young, Alexey Zimarev and RestSharp community -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -namespace RestSharp; - -public class InvalidRequestException : Exception { - public InvalidRequestException(string message, RestRequest? request = null) : base(message) => Request = request; - - public RestRequest? Request { get; } -} \ No newline at end of file diff --git a/src/RestSharp/Request/RequestContent.cs b/src/RestSharp/Request/RequestContent.cs index 5c471dfa7..9df44d6a9 100644 --- a/src/RestSharp/Request/RequestContent.cs +++ b/src/RestSharp/Request/RequestContent.cs @@ -164,7 +164,7 @@ void AddPostParameters(ParametersCollection? postParameters) { var formContent = new FormUrlEncodedContent( _request.Parameters .Where(x => x.Type == ParameterType.GetOrPost) - .Select(x => new KeyValuePair(x.Name!, x.Value!.ToString()!)) + .Select(x => new KeyValuePair(x.Name!, x.Value!.ToString()!))! ); Content = formContent; } diff --git a/src/RestSharp/Response/RestResponse.cs b/src/RestSharp/Response/RestResponse.cs index 6096fa78f..2d97cf11c 100644 --- a/src/RestSharp/Response/RestResponse.cs +++ b/src/RestSharp/Response/RestResponse.cs @@ -70,8 +70,12 @@ CancellationToken cancellationToken return request.AdvancedResponseWriter?.Invoke(httpResponse) ?? await GetDefaultResponse().ConfigureAwait(false); async Task GetDefaultResponse() { - var readTask = request.ResponseWriter == null ? ReadResponse() : ReadAndConvertResponse(); - using var stream = await readTask.ConfigureAwait(false); + var readTask = request.ResponseWriter == null ? ReadResponse() : ReadAndConvertResponse(); +#if NETSTANDARD + using var stream = await readTask.ConfigureAwait(false); +#else + await using var stream = await readTask.ConfigureAwait(false); +#endif var bytes = stream == null ? null : await stream.ReadAsBytes(cancellationToken).ConfigureAwait(false); var content = bytes == null ? null : httpResponse.GetResponseString(bytes, encoding); @@ -109,7 +113,11 @@ async Task GetDefaultResponse() { Task ReadResponse() => httpResponse.ReadResponse(cancellationToken); async Task ReadAndConvertResponse() { +#if NETSTANDARD using var original = await ReadResponse().ConfigureAwait(false); +#else + await using var original = await ReadResponse().ConfigureAwait(false); +#endif return request.ResponseWriter!(original!); } } diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 108fcbc2a..f49a48f46 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -37,11 +37,10 @@ public async Task ExecuteAsync(RestRequest request, CancellationTo ) .ConfigureAwait(false) : AddError(response, internalResponse.Exception, internalResponse.TimeoutToken); - response.Request = request; response.Request.IncreaseNumAttempts(); - + return Options.ThrowOnAnyError ? ThrowIfError(response) : response; } @@ -104,34 +103,28 @@ record InternalResponse(HttpResponseMessage? ResponseMessage, Uri Url, Exception if (response.ResponseMessage == null) return null; if (request.ResponseWriter != null) { +#if NETSTANDARD using var stream = await response.ResponseMessage.ReadResponse(cancellationToken).ConfigureAwait(false); +#else + await using var stream = await response.ResponseMessage.ReadResponse(cancellationToken).ConfigureAwait(false); +#endif return request.ResponseWriter(stream!); } return await response.ResponseMessage.ReadResponse(cancellationToken).ConfigureAwait(false); } - /// - /// A specialized method to download files. - /// - /// Pre-configured request instance. - /// - /// The downloaded file. - [PublicAPI] - public async Task DownloadDataAsync(RestRequest request, CancellationToken cancellationToken = default) { - using var stream = await DownloadStreamAsync(request, cancellationToken).ConfigureAwait(false); - return stream == null ? null : await stream.ReadAsBytes(cancellationToken).ConfigureAwait(false); - } - static RestResponse AddError(RestResponse response, Exception exception, CancellationToken timeoutToken) { response.ResponseStatus = exception is OperationCanceledException - ? timeoutToken.IsCancellationRequested ? ResponseStatus.TimedOut : ResponseStatus.Aborted + ? TimedOut() ? ResponseStatus.TimedOut : ResponseStatus.Aborted : ResponseStatus.Error; response.ErrorMessage = exception.Message; response.ErrorException = exception; return response; + + bool TimedOut() => timeoutToken.IsCancellationRequested || exception.Message.Contains("HttpClient.Timeout"); } internal static RestResponse ThrowIfError(RestResponse response) { @@ -140,7 +133,7 @@ internal static RestResponse ThrowIfError(RestResponse response) { return response; } - + static HttpMethod AsHttpMethod(Method method) => method switch { Method.Get => HttpMethod.Get, diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs index 92838c4bd..0727cc671 100644 --- a/src/RestSharp/RestClient.cs +++ b/src/RestSharp/RestClient.cs @@ -85,6 +85,9 @@ public RestClient(HttpClient httpClient, RestClientOptions? options = null, bool Options = options ?? new RestClientOptions(); CookieContainer = new CookieContainer(); _disposeHttpClient = disposeHttpClient; + if (httpClient.BaseAddress != null && Options.BaseUrl == null) { + Options.BaseUrl = httpClient.BaseAddress; + } ConfigureHttpClient(HttpClient); } diff --git a/src/RestSharp/RestClientExtensions.cs b/src/RestSharp/RestClientExtensions.cs index a376bced1..db38adc08 100644 --- a/src/RestSharp/RestClientExtensions.cs +++ b/src/RestSharp/RestClientExtensions.cs @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Runtime.CompilerServices; +using RestSharp.Extensions; + namespace RestSharp; [PublicAPI] @@ -292,6 +295,60 @@ public static async Task DeleteAsync(this RestClient client, RestR return response; } + /// + /// A specialized method to download files. + /// + /// RestClient instance + /// Pre-configured request instance. + /// + /// The downloaded file. + [PublicAPI] + public static async Task DownloadDataAsync(this RestClient client, RestRequest request, CancellationToken cancellationToken = default) { +#if NETSTANDARD + using var stream = await client.DownloadStreamAsync(request, cancellationToken).ConfigureAwait(false); +#else + await using var stream = await client.DownloadStreamAsync(request, cancellationToken).ConfigureAwait(false); +#endif + return stream == null ? null : await stream.ReadAsBytes(cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads a stream returned by the specified endpoint, deserializes each line to JSON and returns each object asynchronously. + /// It is required for each JSON object to be returned in a single line. + /// + /// + /// + /// + /// + /// + [PublicAPI] + public static async IAsyncEnumerable StreamJsonAsync( + this RestClient client, + string resource, + [EnumeratorCancellation] CancellationToken cancellationToken + ) { + var request = new RestRequest(resource) { CompletionOption = HttpCompletionOption.ResponseHeadersRead }; + +#if NETSTANDARD + using var stream = await client.DownloadStreamAsync(request, cancellationToken).ConfigureAwait(false); +#else + await using var stream = await client.DownloadStreamAsync(request, cancellationToken).ConfigureAwait(false); +#endif + if (stream == null) yield break; + + var serializer = client.Serializers[DataFormat.Json].GetSerializer(); + + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(line)) continue; + + var response = new RestResponse { Content = line }; + yield return serializer.Deserializer.Deserialize(response)!; + } + } + /// /// Sets the to only use JSON /// diff --git a/src/RestSharp/Serializers/Xml/DotNetXmlSerializer.cs b/src/RestSharp/Serializers/Xml/DotNetXmlSerializer.cs index a621c3f70..46ff42b8e 100644 --- a/src/RestSharp/Serializers/Xml/DotNetXmlSerializer.cs +++ b/src/RestSharp/Serializers/Xml/DotNetXmlSerializer.cs @@ -51,7 +51,9 @@ public string Serialize(object obj) { ns.Add(string.Empty, Namespace); - var serializer = new XmlSerializer(obj.GetType()); + var root = RootElement == null ? null : new XmlRootAttribute(RootElement); + + var serializer = new XmlSerializer(obj.GetType(), root); var writer = new EncodingStringWriter(Encoding); serializer.Serialize(writer, obj, ns); diff --git a/test/RestSharp.InteractiveTests/Program.cs b/test/RestSharp.InteractiveTests/Program.cs index 030f0919e..cee0cbbcc 100644 --- a/test/RestSharp.InteractiveTests/Program.cs +++ b/test/RestSharp.InteractiveTests/Program.cs @@ -1,5 +1,13 @@ using RestSharp.InteractiveTests; +var client = new TwitterClient("apikey", "apisecret"); + +await foreach (var tweet in client.SearchStream()) { + Console.WriteLine(tweet); +} + +return; + var keys = new AuthenticationTests.TwitterKeys { ConsumerKey = Prompt("Consumer key"), ConsumerSecret = Prompt("Consumer secret"), diff --git a/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj index 8b96e9ad2..bb6d160b5 100644 --- a/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj +++ b/test/RestSharp.InteractiveTests/RestSharp.InteractiveTests.csproj @@ -4,7 +4,7 @@ false - - + + diff --git a/test/RestSharp.InteractiveTests/TwitterClient.cs b/test/RestSharp.InteractiveTests/TwitterClient.cs new file mode 100644 index 000000000..344b668b1 --- /dev/null +++ b/test/RestSharp.InteractiveTests/TwitterClient.cs @@ -0,0 +1,121 @@ +// Copyright © 2009-2020 John Sheehan, Andrew Young, Alexey Zimarev and RestSharp community +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using RestSharp.Authenticators; + +namespace RestSharp.InteractiveTests; + +public interface ITwitterClient { + Task GetUser(string user); +} + +public class TwitterClient : ITwitterClient, IDisposable { + readonly RestClient _client; + + public TwitterClient(string apiKey, string apiKeySecret) { + var options = new RestClientOptions("https://api.twitter.com/2"); + + _client = new RestClient(options) { + Authenticator = new TwitterAuthenticator("https://api.twitter.com", apiKey, apiKeySecret) + }; + } + + public async Task GetUser(string user) { + var response = await _client.GetJsonAsync>( + "users/by/username/{user}", + new { user } + ); + return response!.Data; + } + + public async Task AddSearchRules(params AddStreamSearchRule[] rules) { + var response = await _client.PostJsonAsync>( + "tweets/search/stream/rules", + new AddSearchRulesRequest(rules) + ); + return response?.Data; + } + + public async Task GetSearchRules() { + var response = await _client.GetJsonAsync>("tweets/search/stream/rules"); + return response?.Data; + } + + public async IAsyncEnumerable SearchStream([EnumeratorCancellation] CancellationToken cancellationToken = default) { + var response = _client.StreamJsonAsync>("tweets/search/stream", cancellationToken); + + await foreach (var item in response.WithCancellation(cancellationToken)) { + yield return item.Data; + } + } + + record TwitterSingleObject(T Data); + + record TwitterCollectionObject(T[] Data); + + record AddSearchRulesRequest(AddStreamSearchRule[] Add); + + public void Dispose() { + _client?.Dispose(); + GC.SuppressFinalize(this); + } +} + +class TwitterAuthenticator : AuthenticatorBase { + readonly string _baseUrl; + readonly string _clientId; + readonly string _clientSecret; + + public TwitterAuthenticator(string baseUrl, string clientId, string clientSecret) : base("") { + _baseUrl = baseUrl; + _clientId = clientId; + _clientSecret = clientSecret; + } + + protected override async ValueTask GetAuthenticationParameter(string accessToken) { + var token = string.IsNullOrEmpty(Token) ? await GetToken() : Token; + return new HeaderParameter(KnownHeaders.Authorization, token); + } + + async Task GetToken() { + var options = new RestClientOptions(_baseUrl); + + using var client = new RestClient(options) { + Authenticator = new HttpBasicAuthenticator(_clientId, _clientSecret), + }; + + var request = new RestRequest("oauth2/token") + .AddParameter("grant_type", "client_credentials"); + var response = await client.PostAsync(request); + return $"{response!.TokenType} {response!.AccessToken}"; + } + + record TokenResponse { + [JsonPropertyName("token_type")] + public string TokenType { get; init; } + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } + } +} + +public record TwitterUser(string Id, string Name, string Username); + +public record AddStreamSearchRule(string Value, string Tag); + +public record SearchRulesResponse(string Value, string Tag, string Id); + +public record SearchResponse(string Id, string Text); \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/HttpClientTests.cs b/test/RestSharp.Tests.Integrated/HttpClientTests.cs new file mode 100644 index 000000000..2e5b2e1ec --- /dev/null +++ b/test/RestSharp.Tests.Integrated/HttpClientTests.cs @@ -0,0 +1,25 @@ +using System.Net; +using RestSharp.Tests.Integrated.Fixtures; + +namespace RestSharp.Tests.Integrated; + +[Collection(nameof(TestServerCollection))] +public class HttpClientTests { + readonly TestServerFixture _fixture; + + public HttpClientTests(TestServerFixture fixture) => _fixture = fixture; + + [Fact] + public async Task ShouldUseBaseAddress() { + using var httpClient = new HttpClient { BaseAddress = _fixture.Server.Url }; + using var client = new RestClient(httpClient); + + var request = new RestRequest("success"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Data!.Message.Should().Be("Works!"); + } + + record Response(string Message); +} \ No newline at end of file diff --git a/test/RestSharp.Tests.Integrated/NonProtocolExceptionHandlingTests.cs b/test/RestSharp.Tests.Integrated/NonProtocolExceptionHandlingTests.cs index 74bfb4f65..8ebb3c4a5 100644 --- a/test/RestSharp.Tests.Integrated/NonProtocolExceptionHandlingTests.cs +++ b/test/RestSharp.Tests.Integrated/NonProtocolExceptionHandlingTests.cs @@ -34,11 +34,17 @@ public async Task Handles_Non_Existent_Domain() { Assert.Equal(ResponseStatus.Error, response.ResponseStatus); } - /// - /// Tests that RestSharp properly handles a non-protocol error. - /// Simulates a server timeout, then verifies that the ErrorException - /// property is correctly populated. - /// + [Fact] + public async Task Handles_HttpClient_Timeout_Error() { + var client = new RestClient(new HttpClient {Timeout = TimeSpan.FromMilliseconds(500)}); + + var request = new RestRequest($"{_server.Url}/404"); + var response = await client.ExecuteAsync(request); + + response.ErrorException.Should().BeOfType(); + response.ResponseStatus.Should().Be(ResponseStatus.TimedOut); + } + [Fact] public async Task Handles_Server_Timeout_Error() { var client = new RestClient(_server.Url); @@ -46,26 +52,19 @@ public async Task Handles_Server_Timeout_Error() { var request = new RestRequest("404") { Timeout = 500 }; var response = await client.ExecuteAsync(request); - Assert.NotNull(response.ErrorException); - Assert.IsType(response.ErrorException); - Assert.Equal(ResponseStatus.TimedOut, response.ResponseStatus); + response.ErrorException.Should().BeOfType(); + response.ResponseStatus.Should().Be(ResponseStatus.TimedOut); } - /// - /// Tests that RestSharp properly handles a non-protocol error. - /// Simulates a server timeout, then verifies that the ErrorException - /// property is correctly populated. - /// [Fact] public async Task Handles_Server_Timeout_Error_With_Deserializer() { var client = new RestClient(_server.Url); var request = new RestRequest("404") { Timeout = 500 }; var response = await client.ExecuteAsync(request); - Assert.Null(response.Data); - Assert.NotNull(response.ErrorException); - Assert.IsType(response.ErrorException); - Assert.Equal(ResponseStatus.TimedOut, response.ResponseStatus); + response.Data.Should().BeNull(); + response.ErrorException.Should().BeOfType(); + response.ResponseStatus.Should().Be(ResponseStatus.TimedOut); } [Fact] diff --git a/test/RestSharp.Tests.Integrated/StatusCodeTests.cs b/test/RestSharp.Tests.Integrated/StatusCodeTests.cs index 746582af0..b9d990d4e 100644 --- a/test/RestSharp.Tests.Integrated/StatusCodeTests.cs +++ b/test/RestSharp.Tests.Integrated/StatusCodeTests.cs @@ -70,8 +70,8 @@ public async Task Handles_Different_Root_Element_On_Http_Error() { var response = await _client.ExecuteAsync(request); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal("Not found!", response.Data.Message); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.Data.Message.Should().Be("Not found!"); } [Fact] @@ -79,7 +79,7 @@ public async Task Handles_GET_Request_404_Error() { var request = new RestRequest("404"); var response = await _client.ExecuteAsync(request); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] @@ -87,7 +87,7 @@ public async Task Reports_1xx_Status_Code_Success_Accurately() { var request = new RestRequest("100"); var response = await _client.ExecuteAsync(request); - Assert.False(response.IsSuccessful); + response.IsSuccessful.Should().BeFalse(); } [Fact] @@ -95,7 +95,7 @@ public async Task Reports_2xx_Status_Code_Success_Accurately() { var request = new RestRequest("204"); var response = await _client.ExecuteAsync(request); - Assert.True(response.IsSuccessful); + response.IsSuccessful.Should().BeTrue(); } [Fact] @@ -103,7 +103,7 @@ public async Task Reports_3xx_Status_Code_Success_Accurately() { var request = new RestRequest("301"); var response = await _client.ExecuteAsync(request); - Assert.False(response.IsSuccessful); + response.IsSuccessful.Should().BeFalse(); } [Fact] @@ -111,7 +111,7 @@ public async Task Reports_4xx_Status_Code_Success_Accurately() { var request = new RestRequest("404"); var response = await _client.ExecuteAsync(request); - Assert.False(response.IsSuccessful); + response.IsSuccessful.Should().BeFalse(); } [Fact] @@ -119,7 +119,7 @@ public async Task Reports_5xx_Status_Code_Success_Accurately() { var request = new RestRequest("503"); var response = await _client.ExecuteAsync(request); - Assert.False(response.IsSuccessful); + response.IsSuccessful.Should().BeFalse(); } } diff --git a/test/RestSharp.Tests.Legacy/RequestBodyTests.cs b/test/RestSharp.Tests.Legacy/RequestBodyTests.cs new file mode 100644 index 000000000..d6193fa19 --- /dev/null +++ b/test/RestSharp.Tests.Legacy/RequestBodyTests.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RestSharp.Tests.Legacy; + +public class RequestBodyTests { + [Fact(Skip = "Setting the content type for GET requests doesn't seem to be possible on Windows")] + [SuppressMessage("Usage", "xUnit1004:Test methods should not be skipped")] + public async Task GetRequestWithContentType() { + var options = new RestClientOptions("https://endim2jwvq8mr.x.pipedream.net/"); + var client = new RestClient(options); + + var request = new RestRequest("resource"); + request.AddHeader("Content-Type", "application/force-download"); + var response = await client.GetAsync(request); + } +} \ No newline at end of file diff --git a/test/RestSharp.Tests.Legacy/RestSharp.Tests.Legacy.csproj b/test/RestSharp.Tests.Legacy/RestSharp.Tests.Legacy.csproj new file mode 100644 index 000000000..5eb4f1271 --- /dev/null +++ b/test/RestSharp.Tests.Legacy/RestSharp.Tests.Legacy.csproj @@ -0,0 +1,15 @@ + + + net48 + disable + + + + + + + + + + +