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(); } } }