using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace RestEase.Implementation
{
///
/// Clas used by generated implementations to make HTTP requests
///
public class Requester : IRequester
{
private readonly HttpClient httpClient;
///
/// Gets or sets the deserializer used to deserialize responses
///
public ResponseDeserializer ResponseDeserializer { get; set; } = new JsonResponseDeserializer();
///
/// Gets or sets the serializer used to serialize request bodies (when [Body(BodySerializationMethod.Serialized)] is used)
///
public RequestBodySerializer RequestBodySerializer { get; set; } = new JsonRequestBodySerializer();
///
/// Gets or sets the serializer used to serialize path parameters (when [Path(PathSerializationMethod.Serialized)] is used)
///
public RequestPathParamSerializer? RequestPathParamSerializer { get; set; }
///
/// Gets or sets the serializer used to serialize query parameters (when [Query(QuerySerializationMethod.Serialized)] is used)
///
public RequestQueryParamSerializer RequestQueryParamSerializer { get; set; } = new JsonRequestQueryParamSerializer();
///
/// Gets or sets the builder used to construct query strings, if any
///
public QueryStringBuilder? QueryStringBuilder { get; set; }
///
/// Gets or sets the used to format items using
///
///
/// Defaults to null, in which case the current culture is used.
///
public IFormatProvider? FormatProvider { get; set; }
///
/// Initialises a new instance of the class, using the given HttpClient
///
/// HttpClient to use to make requests
public Requester(HttpClient httpClient)
{
this.httpClient = httpClient;
}
///
/// Takes the PathParams and PathProperties from the given IRequestInfo, and constructs a path with
/// placeholders substituted for their desired values.
///
///
/// Note that this method assumes that valdation has occurred. That is, there won't by any
/// placeholders with no value, or values without a placeholder.
///
/// Path to substitute placeholders in
/// IRequestInfo to get Path, PathParams, and PathProperties from
/// The constructed path, with placeholders substituted for their actual values
protected virtual string? SubstitutePathParameters(string? path, IRequestInfo requestInfo)
{
if (string.IsNullOrEmpty(path) || (!requestInfo.PathParams.Any() && !requestInfo.PathProperties.Any()))
return path;
// We've already done validation to ensure that the parts in the path, and the available values, are present
// Substitute the path params, then the path properties. That way, the properties are used only if
// there are no matching path params.
var sb = new StringBuilder(path);
foreach (var pathParam in requestInfo.PathParams.Concat(requestInfo.PathProperties))
{
var serialized = this.SerializePathParameter(pathParam, requestInfo);
// Space needs to be treated separately
string? value = pathParam.UrlEncode ? WebUtility.UrlEncode(serialized.Value ?? string.Empty).Replace("+", "%20") : serialized.Value;
sb.Replace("{" + (serialized.Key ?? string.Empty) + "}", value);
}
return sb.ToString();
}
///
/// Given an IRequestInfo and pre-substituted relative path, constructs a URI with the right query parameters
///
/// Base path to start with, with placeholders already substituted
/// Path to start with, with placeholders already substituted
/// IRequestInfo to retrieve the query parameters from
/// Constructed URI; relative if 'path' was relative, otherwise absolute
protected virtual Uri ConstructUri(string basePath, string path, IRequestInfo requestInfo)
{
UriBuilder uriBuilder;
try
{
// If path is absolute, then BaseAddress and basePath are both ignored.
// If path starts with /, then basePath is ignored but BaseAddress is not.
// If basePath is absolute, then BaseAddress is ignored.
string trimmedPath = (path ?? string.Empty).TrimStart('/');
// Here, a leading slash will strip the path from baseAddress
var uri = new Uri(trimmedPath, UriKind.RelativeOrAbsolute);
if (!uri.IsAbsoluteUri && path?.StartsWith("/") != true && !string.IsNullOrEmpty(basePath))
{
string? trimmedBasePath = (basePath ?? string.Empty).TrimStart('/');
// Need to make sure it ends with a trailing slash, or appending our relative path will strip
// the last path component (assuming there is one)
if (!trimmedBasePath.EndsWith("/") && !string.IsNullOrEmpty(uri.OriginalString))
trimmedBasePath += '/';
uri = new Uri(trimmedBasePath + uri.OriginalString, UriKind.RelativeOrAbsolute);
}
if (!uri.IsAbsoluteUri)
{
string? baseAddress = this.httpClient.BaseAddress?.ToString();
if (!string.IsNullOrEmpty(baseAddress))
{
// Need to make sure it ends with a trailing slash, or appending our relative path will strip
// the last path component (assuming there is one)
if (!baseAddress!.EndsWith("/") && !string.IsNullOrEmpty(uri.OriginalString))
baseAddress += '/';
uri = new Uri(baseAddress + uri.OriginalString, UriKind.RelativeOrAbsolute);
}
}
// If it's still relative, 'new UriBuilder(Uri)' won't accept it, but 'new UriBuilder(string)' will
// (by prepending 'http://').
uriBuilder = uri.IsAbsoluteUri ? new UriBuilder(uri) : new UriBuilder(uri.ToString());
}
catch (FormatException e)
{
// The original exception doesn't actually include the path - which is not helpful to the user
throw new FormatException(string.Format("Path '{0}' is not valid: {1}", path, e.Message));
}
string? query = this.BuildQueryParam(uriBuilder.Query, requestInfo.RawQueryParameters, requestInfo.QueryParams, requestInfo.QueryProperties, requestInfo);
// Mono's UriBuilder.Query setter will always add a '?', so we can end up with a double '??'.
uriBuilder.Query = query.TrimStart('?');
return uriBuilder.Uri;
}
///
/// Build up a query string from the initial query string, raw query parameter, and any query params (which need to be combined)
///
/// Initial query string, present from the URI the user specified in the Get/etc parameter
/// The raw query parameters, if any
/// The query parameters which need serializing (or an empty collection)
/// The query parameters from properties which need serialializing (or an empty collection)
/// RequestInfo representing the request
/// Query params combined into a query string
protected virtual string BuildQueryParam(
string initialQueryString,
IEnumerable rawQueryParameters,
IEnumerable queryParams,
IEnumerable queryProperties,
IRequestInfo requestInfo)
{
var serializedQueryParams = queryParams.SelectMany(x => this.SerializeQueryParameter(x, requestInfo));
var serializedQueryProperties = queryProperties.SelectMany(x => this.SerializeQueryParameter(x, requestInfo));
var serializedRawQueryParameters = rawQueryParameters.Select(x => x.SerializeToString(this.FormatProvider));
if (this.QueryStringBuilder != null)
{
var info = new QueryStringBuilderInfo(initialQueryString, serializedRawQueryParameters, serializedQueryParams, serializedQueryProperties, requestInfo, this.FormatProvider);
return this.QueryStringBuilder.Build(info);
}
// Implementation copied from FormUrlEncodedContent
var sb = new StringBuilder();
void AppendQueryString(string query)
{
if (sb.Length > 0)
sb.Append('&');
sb.Append(query);
}
string Encode(string? data)
{
if (string.IsNullOrEmpty(data))
return string.Empty;
return Uri.EscapeDataString(data).Replace("%20", "+");
}
if (!string.IsNullOrEmpty(initialQueryString))
AppendQueryString(initialQueryString.Replace("%20", "+"));
foreach (string? serializedRawQueryParameter in serializedRawQueryParameters)
{
AppendQueryString(serializedRawQueryParameter);
}
foreach (var kvp in serializedQueryParams.Concat(serializedQueryProperties))
{
if (kvp.Key == null)
{
AppendQueryString(Encode(kvp.Value));
}
else
{
AppendQueryString(Encode(this.ToStringHelper(kvp.Key)));
sb.Append('=');
sb.Append(Encode(kvp.Value));
}
}
return sb.ToString();
}
///
/// Given an object, attempt to serialize it into a form suitable for URL Encoding
///
/// Currently only supports objects which implement IDictionary
/// Object to attempt to serialize
/// Key/value collection suitable for URL encoding
protected virtual IEnumerable> SerializeBodyForUrlEncoding(object body)
{
if (body == null)
return Enumerable.Empty>();
if (DictionaryIterator.CanIterate(body.GetType()))
return this.TransformDictionaryToCollectionOfKeysAndValues(body);
else
throw new ArgumentException("BodySerializationMethod is UrlEncoded, but body does not implement IDictionary or IDictionary", nameof(body));
}
///
/// Takes an IDictionary or IDictionary{TKey, TValue}, and emits KeyValuePairs for each key
/// Takes account of IEnumerable values, null values, etc
///
/// Dictionary to transform
/// A set of KeyValuePairs
protected virtual IEnumerable> TransformDictionaryToCollectionOfKeysAndValues(object dictionary)
{
foreach (var kvp in DictionaryIterator.Iterate(dictionary))
{
if (kvp.Value != null && !(kvp.Value is string) && kvp.Value is IEnumerable enumerable)
{
foreach (object individualValue in enumerable)
{
string? stringValue = this.ToStringHelper(individualValue);
yield return new KeyValuePair(this.ToStringHelper(kvp.Key)!, stringValue);
}
}
else if (kvp.Value != null)
{
yield return new KeyValuePair(this.ToStringHelper(kvp.Key)!, this.ToStringHelper(kvp.Value));
}
}
}
///
/// Serializes the value of a path parameter, using an appropriate method
///
/// Path parameter to serialize
/// RequestInfo representing the request
/// Serialized value
protected virtual KeyValuePair SerializePathParameter(PathParameterInfo pathParameter, IRequestInfo requestInfo)
{
switch (pathParameter.SerializationMethod)
{
case PathSerializationMethod.ToString:
return pathParameter.SerializeToString(this.FormatProvider);
case PathSerializationMethod.Serialized:
if (this.RequestPathParamSerializer == null)
throw new InvalidOperationException("Cannot serialize path parameter when RequestPathParamSerializer is null. Please set RequestPathParamSerializer");
var result = pathParameter.SerializeValue(this.RequestPathParamSerializer, requestInfo, this.FormatProvider);
return result;
default:
throw new InvalidOperationException("Should never get here");
}
}
///
/// Serializes the value of a query parameter, using an appropriate method
///
/// Query parameter to serialize
/// RequestInfo representing the request
/// Serialized value
protected virtual IEnumerable> SerializeQueryParameter(QueryParameterInfo queryParameter, IRequestInfo requestInfo)
{
switch (queryParameter.SerializationMethod)
{
case QuerySerializationMethod.ToString:
return queryParameter.SerializeToString(this.FormatProvider);
case QuerySerializationMethod.Serialized:
if (this.RequestQueryParamSerializer == null)
throw new InvalidOperationException("Cannot serialize query parameter when RequestQueryParamSerializer is null. Please set RequestQueryParamSerializer");
var result = queryParameter.SerializeValue(this.RequestQueryParamSerializer, requestInfo, this.FormatProvider);
return result ?? Enumerable.Empty>();
default:
throw new InvalidOperationException("Should never get here");
}
}
///
/// Given an IRequestInfo which may have a BodyParameterInfo, construct a suitable HttpContent for it if possible
///
/// IRequestInfo to get the BodyParameterInfo for
/// null if no body is set, otherwise a suitable HttpContent (StringContent, StreamContent, FormUrlEncodedContent, etc)
protected virtual HttpContent? ConstructContent(IRequestInfo requestInfo)
{
if (requestInfo.BodyParameterInfo == null || requestInfo.BodyParameterInfo.ObjectValue == null)
return null;
if (requestInfo.BodyParameterInfo.ObjectValue is HttpContent httpContentValue)
return httpContentValue;
if (requestInfo.BodyParameterInfo.ObjectValue is Stream streamValue)
return new StreamContent(streamValue);
if (requestInfo.BodyParameterInfo.ObjectValue is string stringValue)
return new StringContent(stringValue);
if (requestInfo.BodyParameterInfo.ObjectValue is byte[] byteArrayValue)
return new ByteArrayContent(byteArrayValue);
switch (requestInfo.BodyParameterInfo.SerializationMethod)
{
case BodySerializationMethod.UrlEncoded:
return new FormUrlEncodedContent(this.SerializeBodyForUrlEncoding(requestInfo.BodyParameterInfo.ObjectValue));
case BodySerializationMethod.Serialized:
if (this.RequestBodySerializer == null)
throw new InvalidOperationException("Cannot serialize request body when RequestBodySerializer is null. Please set RequestBodySerializer");
return requestInfo.BodyParameterInfo.SerializeValue(this.RequestBodySerializer, requestInfo, this.FormatProvider);
default:
throw new InvalidOperationException("Should never get here");
}
}
///
/// Given an IRequestInfo containing a number of class/method/param headers, and a HttpRequestMessage,
/// add the headers to the message, taing priority and overriding into account
///
/// IRequestInfo to get the headers from
/// HttpRequestMessage to add the headers to
protected virtual void ApplyHeaders(IRequestInfo requestInfo, HttpRequestMessage requestMessage)
{
// Apply from class -> method (combining static/dynamic), so we get the proper hierarchy
var classHeaders = requestInfo.ClassHeaders ?? Enumerable.Empty>();
this.ApplyHeadersSet(requestInfo, requestMessage, classHeaders.Concat(requestInfo.PropertyHeaders.Select(x => x.SerializeToString(this.FormatProvider))), false);
this.ApplyHeadersSet(requestInfo, requestMessage, requestInfo.MethodHeaders.Concat(requestInfo.HeaderParams.Select(x => x.SerializeToString(this.FormatProvider))), true);
}
///
/// Given a set of headers, apply them to the given HttpRequestMessage. Headers will override any of that type already present
///
/// RequestInfo for this request
/// HttpRequestMessage to add the headers to
/// Headers to add
/// True if these headers came from the method, false if they came from the class
protected virtual void ApplyHeadersSet(
IRequestInfo requestInfo,
HttpRequestMessage requestMessage,
IEnumerable> headers,
bool areMethodHeaders)
{
HttpContent? dummyContent = null;
var headersGroups = headers.GroupBy(x => x.Key);
foreach (var headersGroup in headersGroups)
{
// Can't use .Contains, as it will throw if the header isn't a valid type
if (requestMessage.Headers.Any(x => x.Key == headersGroup.Key))
requestMessage.Headers.Remove(headersGroup.Key);
// Null values are used to remove instances of a header, but should not be added
string[] headersToAdd = headersGroup.Select(x => x.Value).Where(x => x != null).ToArray()!;
if (!headersToAdd.Any())
continue;
bool added = requestMessage.Headers.TryAddWithoutValidation(headersGroup.Key, headersToAdd);
// If we failed, it's probably a content header. Try again there
if (!added)
{
// If it's a method header, then add a dummy body if necessary
// If it's a class header, then throw only if it isn't a content header (but don't add a dummy body containing it)
if (requestMessage.Content != null)
{
if (requestMessage.Content.Headers.Any(x => x.Key == headersGroup.Key))
requestMessage.Content.Headers.Remove(headersGroup.Key);
added = requestMessage.Content.Headers.TryAddWithoutValidation(headersGroup.Key, headersToAdd);
}
else
{
// If they've added a content header, my reading of the RFC is that we are actually sending a body (even if they haven't
// said what should be in it), and therefore we need to send Content-Length
if (dummyContent == null)
{
dummyContent = new ByteArrayContent(new byte[0]);
}
added = dummyContent.Headers.TryAddWithoutValidation(headersGroup.Key, headersToAdd);
if (added && areMethodHeaders)
{
requestMessage.Content = dummyContent;
}
}
}
// Technically this can be triggered, but I can't come up with any inputs which will
if (!added)
throw new ArgumentException($"Header {headersGroup.Key} could not be added. Maybe it's associated with HTTP responses, or it's malformed?");
}
}
///
/// Serializes an item to a string using if the object implements
///
/// Type of the value being serialized
/// Value being serialized
/// Serialized value
protected string? ToStringHelper(T value) => Implementation.ToStringHelper.ToString(value, null, this.FormatProvider);
///
/// Given an IRequestInfo, construct a HttpRequestMessage, send it, check the response for success, then return it
///
/// IRequestInfo to construct the request from
/// True to pass HttpCompletionOption.ResponseContentRead, meaning that the body is read here
/// Resulting HttpResponseMessage
protected virtual async Task SendRequestAsync(IRequestInfo requestInfo, bool readBody)
{
string basePath = this.SubstitutePathParameters(requestInfo.BasePath, requestInfo) ?? string.Empty;
string path = this.SubstitutePathParameters(requestInfo.Path, requestInfo) ?? string.Empty;
var message = new HttpRequestMessage()
{
Method = requestInfo.Method,
RequestUri = this.ConstructUri(basePath, path, requestInfo),
Content = this.ConstructContent(requestInfo),
Properties = { { RestClient.HttpRequestMessageRequestInfoPropertyKey, requestInfo } },
};
this.ApplyHttpRequestMessageProperties(requestInfo, message);
// Do this after setting the content, as doing so may set headers which we want to remove / override
this.ApplyHeaders(requestInfo, message);
// If we're deserializing, we're always going to want the content, since we're always going to deserialize it.
// Therefore use HttpCompletionOption.ResponseContentRead so that the content gets read at this point, meaning
// that it can be cancelled by our CancellationToken.
// However if we're returning a HttpResponseMessage, that's probably because the user wants to read it themselves.
// They might want to only fetch the first bit, or stream it into a file, or get process on it. In this case,
// we'll want HttpCompletionOption.ResponseHeadersRead.
var completionOption = readBody ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead;
var response = await this.httpClient.SendAsync(message, completionOption, requestInfo.CancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode && !requestInfo.AllowAnyStatusCode)
throw await ApiException.CreateAsync(message, response).ConfigureAwait(false);
return response;
}
private void ApplyHttpRequestMessageProperties(IRequestInfo requestInfo, HttpRequestMessage requestMessage)
{
foreach (var property in requestInfo.HttpRequestMessageProperties)
{
requestMessage.Properties.Add(property.Key, property.Value);
}
}
///
/// Calls this.ResponseDeserializer.ReadAndDeserializeAsync, after checking it's not null
///
/// Type of object to deserialize into
/// String content read from the response
/// Response to deserialize from
/// RequestInfo representing the request
/// A task containing the deserialized response
protected virtual T Deserialize(string? content, HttpResponseMessage response, IRequestInfo requestInfo)
{
if (this.ResponseDeserializer == null)
throw new InvalidOperationException("Cannot deserialize a response when ResponseDeserializer is null. Please set ResponseDeserializer");
return this.ResponseDeserializer.Deserialize(content, response, new ResponseDeserializerInfo(requestInfo));
}
///
/// Called from interface methods which return a Task
///
/// IRequestInfo to construct the request from
/// Task which completes when the request completed
public virtual async Task RequestVoidAsync(IRequestInfo requestInfo)
{
// We're not going to return the body (unless there's an ApiException), so there's no point in fetching it
using (await this.SendRequestAsync(requestInfo, readBody: false).ConfigureAwait(false))
{
}
}
///
/// Called from interface methods which return a Task{CustomType}. Deserializes and returns the response
///
/// Type of the response, to deserialize into
/// IRequestInfo to construct the request from
/// Task resulting in the deserialized response
public virtual async Task RequestAsync(IRequestInfo requestInfo)
{
using (var response = await this.SendRequestAsync(requestInfo, readBody: true).ConfigureAwait(false))
{
string? content = response.Content == null ?
null :
await response.Content.ReadAsStringAsync().ConfigureAwait(false);
T deserializedResponse = this.Deserialize(content, response, requestInfo);
return deserializedResponse;
}
}
///
/// Called from interface methods which return a Task{HttpResponseMessage}
///
/// IRequestInfo to construct the request from
/// Task containing the result of the request
public virtual async Task RequestWithResponseMessageAsync(IRequestInfo requestInfo)
{
// It's the user's responsibility to dispose this
var response = await this.SendRequestAsync(requestInfo, readBody: false).ConfigureAwait(false);
return response;
}
///
/// Called from interface methods which return a Task{Response{T}}
///
/// Type of the response, to deserialize into
/// IRequestInfo to construct the request from
/// Task containing a Response{T}, which contains the raw HttpResponseMessage, and its deserialized content
public virtual async Task> RequestWithResponseAsync(IRequestInfo requestInfo)
{
// It's the user's responsibility to dispose the Response, which disposes the HttpResponseMessage
var response = await this.SendRequestAsync(requestInfo, readBody: true).ConfigureAwait(false);
string? content = response.Content == null ?
null :
await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return new Response(content, response, () => this.Deserialize(content, response, requestInfo));
}
///
/// Called from interface methods which return a Task{string}
///
/// IRequestInfo to construct the request from
/// Task containing the raw string body of the response
public virtual async Task RequestRawAsync(IRequestInfo requestInfo)
{
using (var response = await this.SendRequestAsync(requestInfo, readBody: true).ConfigureAwait(false))
{
string? responseString = response.Content == null ?
null :
await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return responseString;
}
}
///
/// Invoked when the API interface method being called returns a Task{Stream}
///
/// Object holding all information about the request
/// Task to return to the API interface caller
public virtual async Task RequestStreamAsync(IRequestInfo requestInfo)
{
// Disposing the HttpResponseMessage will dispose the Stream (indeed, that's the only reason when
// HttpResponseMessage is IDisposable), which the user wants to use. Since the HttpResponseMessage
// is only IDisposable to dispose the Stream, provided that the user disposes the Stream themselves,
// nothing will leak.
var response = await this.SendRequestAsync(requestInfo, readBody: false).ConfigureAwait(false);
var stream = response.Content == null ?
null :
await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
return stream;
}
///
/// Disposes the underlying
///
public void Dispose()
{
this.httpClient.Dispose();
}
}
}