diff --git a/src/KubeClient/Http/FactoryExtensions.cs b/src/KubeClient/Http/FactoryExtensions.cs new file mode 100644 index 0000000..44f698f --- /dev/null +++ b/src/KubeClient/Http/FactoryExtensions.cs @@ -0,0 +1,35 @@ +using System; + +namespace KubeClient.Http +{ + /// + /// Extension methods for . + /// + public static class FactoryExtensions + { + /// + /// Create a new HTTP request with the specified request URI. + /// + /// + /// The HTTP request factory. + /// + /// + /// The request URI (can be relative or absolute). + /// + /// + /// The new . + /// + public static HttpRequest Create(this HttpRequestFactory requestFactory, string requestUri) + { + if (requestFactory == null) + throw new ArgumentNullException(nameof(requestFactory)); + + if (String.IsNullOrWhiteSpace(requestUri)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'requestUri'.", nameof(requestUri)); + + return requestFactory.Create( + new Uri(requestUri, UriKind.RelativeOrAbsolute) + ); + } + } +} diff --git a/src/KubeClient/Http/HttpRequest.cs b/src/KubeClient/Http/HttpRequest.cs new file mode 100644 index 0000000..0c32b5a --- /dev/null +++ b/src/KubeClient/Http/HttpRequest.cs @@ -0,0 +1,407 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; + +namespace KubeClient.Http +{ + using Utilities; + using ValueProviders; + + using RequestProperties = ImmutableDictionary; + + /// + /// A template for an HTTP request. + /// + public sealed class HttpRequest + : HttpRequestBase, IHttpRequest, IHttpRequest + { + #region Constants + + /// + /// The used a context for all untyped HTTP requests. + /// + internal static readonly object DefaultContext = new object(); + + /// + /// The base properties for s. + /// + static readonly RequestProperties BaseProperties = + new Dictionary + { + [nameof(RequestActions)] = ImmutableList>.Empty, + [nameof(ResponseActions)] = ImmutableList>.Empty, + [nameof(TemplateParameters)] = ImmutableDictionary>.Empty, + [nameof(QueryParameters)] = ImmutableDictionary>.Empty + } + .ToImmutableDictionary(); + + /// + /// An empty . + /// + public static readonly HttpRequest Empty = new HttpRequest(BaseProperties); + + /// + /// The default factory for s. + /// + public static HttpRequestFactory Factory { get; } = new HttpRequestFactory(Empty); + + #endregion // Constants + + #region Construction + + /// + /// Create a new HTTP request. + /// + /// + /// The request properties. + /// + HttpRequest(ImmutableDictionary properties) + : base(properties) + { + EnsurePropertyType>>( + propertyName: nameof(RequestActions) + ); + EnsurePropertyType>>( + propertyName: nameof(TemplateParameters) + ); + EnsurePropertyType>>( + propertyName: nameof(QueryParameters) + ); + } + + /// + /// Create a new HTTP request with the specified request URI. + /// + /// + /// The request URI (can be relative or absolute). + /// + /// + /// The new . + /// + public static HttpRequest Create(string requestUri) => Factory.Create(requestUri); + + /// + /// Create a new HTTP request with the specified request URI. + /// + /// + /// The request URI (can be relative or absolute). + /// + /// + /// The new . + /// + public static HttpRequest Create(Uri requestUri) => Factory.Create(requestUri); + + #endregion // Construction + + #region Properties + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + public ImmutableList> RequestActions => GetProperty>>(); + + /// + /// Actions (if any) to perform on the incoming response message. + /// + public ImmutableList> ResponseActions => GetProperty>>(); + + /// + /// The request's URI template parameters (if any). + /// + public ImmutableDictionary> TemplateParameters => GetProperty>>(); + + /// + /// The request's query parameters (if any). + /// + public ImmutableDictionary> QueryParameters => GetProperty>>(); + + #endregion // Properties + + #region Invocation + + /// + /// Build the request URI represented by the . + /// + /// + /// An optional base URI to use if the request does not already have an absolute request URI. + /// + /// + /// The request URI. + /// + public Uri BuildRequestUri(Uri baseUri = null) + { + // Ensure we have an absolute URI. + Uri requestUri = Uri; + if (requestUri == null) + throw new InvalidOperationException("Cannot build a request message; the request does not have a URI."); + + if (!requestUri.IsAbsoluteUri) + { + if (baseUri == null) + throw new InvalidOperationException("Cannot build a request message; the request does not have an absolute request URI, and no base URI was supplied."); + + // Make relative to base URI. + requestUri = baseUri.AppendRelativeUri(requestUri); + } + else + { + // Extract base URI to which request URI is already (by definition) relative. + baseUri = new Uri( + requestUri.GetComponents( + UriComponents.Scheme | UriComponents.StrongAuthority, + UriFormat.UriEscaped + ) + ); + } + + if (IsUriTemplate) + { + UriTemplate template = new UriTemplate( + requestUri.GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped) + ); + + IDictionary templateParameterValues = GetTemplateParameterValues(); + + requestUri = template.Populate(baseUri, templateParameterValues); + } + + // Merge in any other query parameters defined directly on the request. + requestUri = MergeQueryParameters(requestUri); + + return requestUri; + } + + /// + /// Build and configure a new HTTP request message. + /// + /// + /// The HTTP request method to use. + /// + /// + /// Optional representing the request body. + /// + /// + /// An optional base URI to use if the request does not already have an absolute request URI. + /// + /// + /// The configured . + /// + public HttpRequestMessage BuildRequestMessage(HttpMethod httpMethod, HttpContent body = null, Uri baseUri = null) + { + if (httpMethod == null) + throw new ArgumentNullException(nameof(httpMethod)); + + Uri requestUri = BuildRequestUri(baseUri); + + HttpRequestMessage requestMessage = null; + try + { + requestMessage = new HttpRequestMessage(httpMethod, requestUri); + SetStandardMessageProperties(requestMessage); + + if (body != null) + requestMessage.Content = body; + + List configurationActionExceptions = new List(); + foreach (RequestAction requestAction in RequestActions) + { + if (requestAction == null) + continue; + + try + { + requestAction(requestMessage, DefaultContext); + } + catch (Exception eConfigurationAction) + { + configurationActionExceptions.Add(eConfigurationAction); + } + } + + if (configurationActionExceptions.Count > 0) + { + throw new AggregateException( + "One or more unhandled exceptions were encountered while configuring the outgoing request message.", + configurationActionExceptions + ); + } + } + catch + { + using (requestMessage) + { + throw; + } + } + + return requestMessage; + } + + /// + /// Build and configure a new HTTP request message. + /// + /// + /// The HTTP request method to use. + /// + /// + /// The object used as a context for resolving deferred template values. + /// + /// + /// Optional representing the request body. + /// + /// + /// An optional base URI to use if the request does not already have an absolute request URI. + /// + /// + /// The configured . + /// + HttpRequestMessage IHttpRequest.BuildRequestMessage(HttpMethod httpMethod, object context, HttpContent body, Uri baseUri) + { + return BuildRequestMessage(httpMethod, body, baseUri); + } + + #endregion // Invocation + + #region IHttpRequestProperties + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + IReadOnlyList> IHttpRequestProperties.RequestActions => RequestActions; + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + IReadOnlyList> IHttpRequestProperties.ResponseActions => ResponseActions; + + /// + /// The request's URI template parameters (if any). + /// + IReadOnlyDictionary> IHttpRequestProperties.TemplateParameters => TemplateParameters; + + /// + /// The request's query parameters (if any). + /// + IReadOnlyDictionary> IHttpRequestProperties.QueryParameters => QueryParameters; + + #endregion // IHttpRequestProperties + + #region Cloning + + /// + /// Clone the request. + /// + /// + /// A delegate that performs modifications to the request properties. + /// + /// + /// The cloned request. + /// + public new HttpRequest Clone(Action> modifications) + { + if (modifications == null) + throw new ArgumentNullException(nameof(modifications)); + + return (HttpRequest)base.Clone(modifications); + } + + /// + /// Create a new instance of the HTTP request using the specified properties. + /// + /// + /// The request properties. + /// + /// + /// The new HTTP request instance. + /// + protected override HttpRequestBase CreateInstance(ImmutableDictionary requestProperties) + { + return new HttpRequest(requestProperties); + } + + #endregion // Cloning + + #region Helpers + + /// + /// Merge the request's query parameters (if any) into the request URI. + /// + /// + /// The request URI. + /// + /// + /// The request URI with query parameters merged into it. + /// + Uri MergeQueryParameters(Uri requestUri) + { + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + if (QueryParameters.Count == 0) + return requestUri; + + NameValueCollection queryParameters = requestUri.ParseQueryParameters(); + foreach (KeyValuePair> queryParameter in QueryParameters) + { + string queryParameterValue = queryParameter.Value.Get(DefaultContext); + if (queryParameterValue != null) + queryParameters[queryParameter.Key] = queryParameterValue; + else + queryParameters.Remove(queryParameter.Key); + } + + return requestUri.WithQueryParameters(queryParameters); + } + + /// + /// Get a dictionary mapping template parameters (if any) to their current values. + /// + /// + /// A dictionary of key / value pairs (any parameters whose value-getters return null will be omitted). + /// + IDictionary GetTemplateParameterValues() + { + return + TemplateParameters.Select(templateParameter => + { + Debug.Assert(templateParameter.Value != null); + + return new + { + templateParameter.Key, + Value = templateParameter.Value.Get(DefaultContext) + }; + }) + .Where( + templateParameter => templateParameter.Value != null + ) + .ToDictionary( + templateParameter => templateParameter.Key, + templateParameter => templateParameter.Value + ); + } + + /// + /// Configure standard properties for the specified . + /// + /// + /// The . + /// + void SetStandardMessageProperties(HttpRequestMessage requestMessage) + { + if (requestMessage == null) + throw new ArgumentNullException(nameof(requestMessage)); + + // TODO: Switch to HttpRequestOptions once we drop netstandard2.1 support. +#pragma warning disable CS0618 // Type or member is obsolete + requestMessage.Properties[MessageProperties.Request] = this; +#pragma warning restore CS0618 // Type or member is obsolete + } + + #endregion // Helpers + } +} diff --git a/src/KubeClient/Http/HttpRequestException.cs b/src/KubeClient/Http/HttpRequestException.cs new file mode 100644 index 0000000..fca7373 --- /dev/null +++ b/src/KubeClient/Http/HttpRequestException.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Net.Http; + +namespace KubeClient.Http +{ + /// + /// Exception thrown when an error response is received while making an HTTP request. + /// + /// + /// TODO: Throw this from response.ReadContentAsAsync<TResponse, TErrorResponse>. + /// + public class HttpRequestException + : HttpRequestException + { + /// + /// Create a new . + /// + /// + /// The response's HTTP status code. + /// + /// + /// The response body. + /// + public HttpRequestException(HttpStatusCode statusCode, TResponse response) + : this(statusCode, response, $"The request failed with unexpected status code '{statusCode}'.") + { + } + +#if NETSTANDARD2_1 + /// + /// Create a new . + /// + /// + /// The response's HTTP status code. + /// + /// + /// The response body. + /// + /// + /// The exception message. + /// + public HttpRequestException(HttpStatusCode statusCode, TResponse response, string message) + : base(message) + { + StatusCode = statusCode; + Response = response; + } +#else // NETSTANDARD2_1 + /// + /// Create a new . + /// + /// + /// The response's HTTP status code. + /// + /// + /// The response body. + /// + /// + /// The exception message. + /// + public HttpRequestException(HttpStatusCode statusCode, TResponse response, string message) + : base(message, inner: null, statusCode) + { + Response = response; + } +#endif // NETSTANDARD2_1 + +#if NETSTANDARD2_1 + /// + /// The response's HTTP status code. + /// + public HttpStatusCode StatusCode { get; } +#endif // NETSTANDARD2_1 + + /// + /// The response body. + /// + public TResponse Response { get; } + + /// + /// Create a new . + /// + /// + /// The HTTP response status code. + /// + /// + /// A representing the HTTP response body. + /// + /// + /// The configured . + /// + public static HttpRequestException Create(HttpStatusCode statusCode, TResponse response) + { + string message = $"HTTP request failed ({statusCode})."; + + IHttpErrorResponse errorResponse = response as IHttpErrorResponse; + if (errorResponse != null) + message = errorResponse.GetExceptionMesage(); + + return new HttpRequestException(statusCode, response, message); + } + } +} diff --git a/src/KubeClient/Http/HttpRequestFactory.cs b/src/KubeClient/Http/HttpRequestFactory.cs new file mode 100644 index 0000000..27e5d6b --- /dev/null +++ b/src/KubeClient/Http/HttpRequestFactory.cs @@ -0,0 +1,91 @@ +using System; + +namespace KubeClient.Http +{ + /// + /// A facility for creating s. + /// + public sealed class HttpRequestFactory + { + /// + /// Create a new . + /// + /// + /// The used as a base for requests created by the factory. + /// + public HttpRequestFactory(HttpRequest baseRequest) + { + if (baseRequest == null) + throw new ArgumentNullException(nameof(baseRequest)); + + BaseRequest = baseRequest; + } + + /// + /// The used as a base for requests created by the factory. + /// + public HttpRequest BaseRequest { get; } + + /// + /// Create a new with the specified request URI. + /// + /// + /// The request URI. + /// + /// + /// The new . + /// + public HttpRequest Create(Uri requestUri) + { + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + return BaseRequest.WithUri(requestUri); + } + } + + /// + /// A facility for creating s. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + public sealed class HttpRequestFactory + { + /// + /// Create a new . + /// + /// + /// The used as a base for requests created by the factory. + /// + public HttpRequestFactory(HttpRequest baseRequest) + { + if (baseRequest == null) + throw new ArgumentNullException(nameof(baseRequest)); + + BaseRequest = baseRequest; + } + + /// + /// The used as a base for requests created by the factory. + /// + public HttpRequest BaseRequest { get; } + + /// + /// Create a new with the specified request URI. + /// + /// + /// The request URI. + /// + /// + /// The new . + /// + public HttpRequest Create(Uri requestUri) + { + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + return BaseRequest.WithUri(requestUri); + } + } +} diff --git a/src/KubeClient/Http/HttpRequestOfTContext.cs b/src/KubeClient/Http/HttpRequestOfTContext.cs new file mode 100644 index 0000000..cf77fd8 --- /dev/null +++ b/src/KubeClient/Http/HttpRequestOfTContext.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; + +namespace KubeClient.Http +{ + using Utilities; + using ValueProviders; + + using RequestProperties = ImmutableDictionary; + + /// + /// A template for an HTTP request that resolves deferred values from an instance of . + /// + /// + /// The type of object used as a context for resolving deferred values. + /// + public class HttpRequest + : HttpRequestBase, IHttpRequest + { + #region Constants + + /// + /// The base properties for s. + /// + static readonly RequestProperties BaseProperties = + new Dictionary + { + [nameof(RequestActions)] = ImmutableList>.Empty, + [nameof(ResponseActions)] = ImmutableList>.Empty, + [nameof(TemplateParameters)] = ImmutableDictionary>.Empty, + [nameof(QueryParameters)] = ImmutableDictionary>.Empty + } + .ToImmutableDictionary(); + + /// + /// An empty . + /// + public static HttpRequest Empty = new HttpRequest(BaseProperties); + + /// + /// The default factory for s. + /// + public static HttpRequestFactory Factory { get; } = new HttpRequestFactory(Empty); + + #endregion // Constants + + #region Construction + + /// + /// Create a new HTTP request. + /// + /// + /// The request properties. + /// + HttpRequest(ImmutableDictionary properties) + : base(properties) + { + EnsurePropertyType>>( + propertyName: nameof(RequestActions) + ); + EnsurePropertyType>>( + propertyName: nameof(TemplateParameters) + ); + EnsurePropertyType>>( + propertyName: nameof(QueryParameters) + ); + } + + /// + /// Create a new HTTP request with the specified request URI. + /// + /// + /// The request URI (can be relative or absolute). + /// + /// + /// The new . + /// + public static HttpRequest Create(string requestUri) => Factory.Create(requestUri); + + /// + /// Create a new HTTP request with the specified request URI. + /// + /// + /// The request URI (can be relative or absolute). + /// + /// + /// The new . + /// + public static HttpRequest Create(Uri requestUri) => Factory.Create(requestUri); + + #endregion // Construction + + #region Properties + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + public ImmutableList> RequestActions => GetProperty>>(); + + /// + /// Actions (if any) to perform on the incoming response message. + /// + public ImmutableList> ResponseActions => GetProperty>>(); + + /// + /// The request's URI template parameters (if any). + /// + public ImmutableDictionary> TemplateParameters => GetProperty>>(); + + /// + /// The request's query parameters (if any). + /// + public ImmutableDictionary> QueryParameters => GetProperty>>(); + + #endregion // Properties + + #region Invocation + + /// + /// Build and configure a new HTTP request message. + /// + /// + /// The HTTP request method to use. + /// + /// + /// The object used as a context for resolving deferred template values. + /// + /// + /// Optional representing the request body. + /// + /// + /// An optional base URI to use if the request does not already have an absolute request URI. + /// + /// + /// The configured . + /// + public HttpRequestMessage BuildRequestMessage(HttpMethod httpMethod, TContext context, HttpContent body = null, Uri baseUri = null) + { + if (httpMethod == null) + throw new ArgumentNullException(nameof(httpMethod)); + + // Ensure we have an absolute URI. + Uri requestUri = Uri; + if (requestUri == null) + throw new InvalidOperationException("Cannot build a request message; the request does not have a URI."); + + if (!requestUri.IsAbsoluteUri) + { + if (baseUri == null) + throw new InvalidOperationException("Cannot build a request message; the request does not have an absolute request URI, and no base URI was supplied."); + + // Make relative to base URI. + requestUri = baseUri.AppendRelativeUri(requestUri); + } + else + { + // Extract base URI to which request URI is already (by definition) relative. + baseUri = new Uri( + requestUri.GetComponents( + UriComponents.Scheme | UriComponents.StrongAuthority, + UriFormat.UriEscaped + ) + ); + } + + if (IsUriTemplate) + { + UriTemplate template = new UriTemplate( + requestUri.GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped) + ); + + IDictionary templateParameterValues = GetTemplateParameterValues(context); + + requestUri = template.Populate(baseUri, templateParameterValues); + } + + // Merge in any other query parameters defined directly on the request. + requestUri = MergeQueryParameters(requestUri, context); + + HttpRequestMessage requestMessage = null; + try + { + requestMessage = new HttpRequestMessage(httpMethod, requestUri); + SetStandardMessageProperties(requestMessage); + + if (body != null) + requestMessage.Content = body; + + List configurationActionExceptions = new List(); + foreach (RequestAction requestAction in RequestActions) + { + if (requestAction == null) + continue; + + try + { + requestAction(requestMessage, context); + } + catch (Exception eConfigurationAction) + { + configurationActionExceptions.Add(eConfigurationAction); + } + } + + if (configurationActionExceptions.Count > 0) + { + throw new AggregateException( + "One or more unhandled exceptions were encountered while configuring the outgoing request message.", + configurationActionExceptions + ); + } + } + catch + { + using (requestMessage) + { + throw; + } + } + + return requestMessage; + } + + #endregion // Invocation + + #region IHttpRequest + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + IReadOnlyList> IHttpRequestProperties.RequestActions => RequestActions; + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + IReadOnlyList> IHttpRequestProperties.ResponseActions => ResponseActions; + + /// + /// The request's URI template parameters (if any). + /// + IReadOnlyDictionary> IHttpRequestProperties.TemplateParameters => TemplateParameters; + + /// + /// The request's query parameters (if any). + /// + IReadOnlyDictionary> IHttpRequestProperties.QueryParameters => QueryParameters; + + #endregion // IHttpRequest + + #region Cloning + + /// + /// Clone the request. + /// + /// + /// A delegate that performs modifications to the request properties. + /// + /// + /// The cloned request. + /// + public new HttpRequest Clone(Action> modifications) + { + if (modifications == null) + throw new ArgumentNullException(nameof(modifications)); + + return (HttpRequest)base.Clone(modifications); + } + + /// + /// Create a new instance of the HTTP request using the specified properties. + /// + /// + /// The request properties. + /// + /// + /// The new HTTP request instance. + /// + protected override HttpRequestBase CreateInstance(ImmutableDictionary requestProperties) + { + return new HttpRequest(requestProperties); + } + + #endregion // Cloning + + #region Helpers + + /// + /// Merge the request's query parameters (if any) into the request URI. + /// + /// + /// The request URI. + /// + /// + /// The from which parameter values will be resolved. + /// + /// + /// The request URI with query parameters merged into it. + /// + Uri MergeQueryParameters(Uri requestUri, TContext context) + { + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + if (QueryParameters.Count == 0) + return requestUri; + + NameValueCollection queryParameters = requestUri.ParseQueryParameters(); + foreach (KeyValuePair> queryParameter in QueryParameters) + { + string queryParameterValue = queryParameter.Value.Get(context); + if (queryParameterValue != null) + queryParameters[queryParameter.Key] = queryParameterValue; + else + queryParameters.Remove(queryParameter.Key); + } + + return requestUri.WithQueryParameters(queryParameters); + } + + /// + /// Get a dictionary mapping template parameters (if any) to their current values. + /// + /// + /// The from which parameter values will be resolved. + /// + /// + /// A dictionary of key / value pairs (any parameters whose value-getters return null will be omitted). + /// + IDictionary GetTemplateParameterValues(TContext context) + { + return + TemplateParameters.Select(templateParameter => + { + Debug.Assert(templateParameter.Value != null); + + return new + { + templateParameter.Key, + Value = templateParameter.Value.Get(context) + }; + }) + .Where( + templateParameter => templateParameter.Value != null + ) + .ToDictionary( + templateParameter => templateParameter.Key, + templateParameter => templateParameter.Value + ); + } + + /// + /// Configure standard properties for the specified . + /// + /// + /// The . + /// + void SetStandardMessageProperties(HttpRequestMessage requestMessage) + { + if (requestMessage == null) + throw new ArgumentNullException(nameof(requestMessage)); + + // TODO: Switch to HttpRequestOptions once we drop netstandard2.1 support. +#pragma warning disable CS0618 // Type or member is obsolete + requestMessage.Properties[MessageProperties.Request] = this; +#pragma warning restore CS0618 // Type or member is obsolete + } + + #endregion // Helpers + } +} diff --git a/src/KubeClient/Http/HttpResponse.cs b/src/KubeClient/Http/HttpResponse.cs new file mode 100644 index 0000000..c163e7a --- /dev/null +++ b/src/KubeClient/Http/HttpResponse.cs @@ -0,0 +1,105 @@ +using System; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace KubeClient.Http +{ + /// + /// The response from an asynchronous invocation of an . + /// + public struct HttpResponse + { + /// + /// Create a new . + /// + /// + /// The request whose response is represented by the . + /// + /// + /// The underlying represented by the . + /// + public HttpResponse(HttpRequest request, Task task) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (task == null) + throw new ArgumentNullException(nameof(task)); + + Request = request; + Task = task; + } + + /// + /// Create a new for the specified asynchronous action. + /// + /// + /// The request whose response is represented by the . + /// + /// + /// An asynchronous delegate that produces the action's resulting . + /// + public HttpResponse(HttpRequest request, Func> asyncAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (asyncAction == null) + throw new ArgumentNullException(nameof(asyncAction)); + + Request = request; + Task = asyncAction(); + if (Task == null) + throw new InvalidOperationException("The asynchronous action delegate returned null."); + } + + /// + /// The request whose response is represented by the . + /// + public HttpRequest Request { get; set; } + + /// + /// The underlying represented by the . + /// + public Task Task { get; } + + // TODO: Considering something like Promise's "Then" method to encapsulate the construction of a new HttpResponse using the same HttpRequest but a new async action. + + /// + /// Get an awaiter for the underlying represented by the . + /// + /// + /// The task awaiter. + /// + /// + /// Enables directly awaiting the . + /// + public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); + + /// + /// Configure the way that the response's task is awaited. + /// + /// + /// Should the awaited task return to the ambient synchronisation context? + /// + /// + /// A that can be awaited. + /// + public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) => Task.ConfigureAwait(continueOnCapturedContext); + + /// + /// Implicit conversion from to + /// + /// + /// The to convert. + /// + /// + /// The 's . + /// + public static implicit operator Task(HttpResponse httpResponse) + { + return httpResponse.Task; + } + } +} \ No newline at end of file diff --git a/src/KubeClient/Http/IHttpErrorResponse.cs b/src/KubeClient/Http/IHttpErrorResponse.cs new file mode 100644 index 0000000..4439025 --- /dev/null +++ b/src/KubeClient/Http/IHttpErrorResponse.cs @@ -0,0 +1,13 @@ +namespace KubeClient.Http +{ + /// + /// Represents an HTTP error response whose properties can be used to populate an . + /// + public interface IHttpErrorResponse + { + /// + /// Get the exception message associated with the response. + /// + string GetExceptionMesage(); + } +} \ No newline at end of file diff --git a/src/KubeClient/Http/IHttpRequest.cs b/src/KubeClient/Http/IHttpRequest.cs new file mode 100644 index 0000000..4756b3b --- /dev/null +++ b/src/KubeClient/Http/IHttpRequest.cs @@ -0,0 +1,59 @@ +using System; +using System.Net.Http; + +namespace KubeClient.Http +{ + /// + /// Represents a template for building HTTP requests. + /// + public interface IHttpRequest + : IHttpRequestProperties + { + /// + /// Build and configure a new HTTP request message. + /// + /// + /// The HTTP request method to use. + /// + /// + /// Optional representing the request body. + /// + /// + /// An optional base URI to use if the request builder does not already have an absolute request URI. + /// + /// + /// The configured . + /// + HttpRequestMessage BuildRequestMessage(HttpMethod httpMethod, HttpContent body = null, Uri baseUri = null); + } + + /// + /// Represents a template for building HTTP requests with lazily-resolved values extracted from a specific context. + /// + /// + /// The type of object used by the request when resolving deferred values. + /// + public interface IHttpRequest + : IHttpRequestProperties + { + /// + /// Build and configure a new HTTP request message. + /// + /// + /// The HTTP request method to use. + /// + /// + /// The to use as the context for resolving any deferred template or query parameters. + /// + /// + /// Optional representing the request body. + /// + /// + /// An optional base URI to use if the request builder does not already have an absolute request URI. + /// + /// + /// The configured . + /// + HttpRequestMessage BuildRequestMessage(HttpMethod httpMethod, TContext context, HttpContent body = null, Uri baseUri = null); + } +} diff --git a/src/KubeClient/Http/IHttpRequestProperties.cs b/src/KubeClient/Http/IHttpRequestProperties.cs new file mode 100644 index 0000000..72673a6 --- /dev/null +++ b/src/KubeClient/Http/IHttpRequestProperties.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// Represents common properties of templates for building HTTP requests. + /// + public interface IHttpRequestProperties + { + /// + /// The request URI. + /// + Uri Uri + { + get; + } + + /// + /// Is the request URI a template? + /// + bool IsUriTemplate + { + get; + } + + /// + /// Additional properties for the request. + /// + ImmutableDictionary Properties + { + get; + } + } + + /// + /// Represents common properties of templates for building HTTP requests. + /// + /// + /// The type of object used as a context for resolving deferred template parameters. + /// + public interface IHttpRequestProperties + : IHttpRequestProperties + { + /// + /// Actions (if any) to perform on the outgoing request message. + /// + IReadOnlyList> RequestActions { get; } + + /// + /// Actions (if any) to perform on the outgoing request message. + /// + IReadOnlyList> ResponseActions { get; } + + /// + /// The request's URI template parameters (if any). + /// + IReadOnlyDictionary> TemplateParameters { get; } + + /// + /// The request's query parameters (if any). + /// + IReadOnlyDictionary> QueryParameters { get; } + } +} diff --git a/src/KubeClient/Http/MessageExtensions.cs b/src/KubeClient/Http/MessageExtensions.cs new file mode 100644 index 0000000..f1b01ca --- /dev/null +++ b/src/KubeClient/Http/MessageExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.Net.Http; + +namespace KubeClient.Http +{ + /// + /// Extension methods for / . + /// + public static class MessageExtensions + { + /// + /// Determine whether the request message has been configured for a streamed response. + /// + /// + /// The HTTP request message. + /// + /// + /// true, if the request message has been configured for a streamed response; otherwise, false. + /// + public static bool IsStreamed(this HttpRequestMessage message) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + object isStreamedValue; + + // TODO: Switch to HttpRequestOptions once we drop netstandard2.1 support. +#pragma warning disable CS0618 // Type or member is obsolete + message.Properties.TryGetValue(MessageProperties.IsStreamed, out isStreamedValue); +#pragma warning restore CS0618 // Type or member is obsolete + + return (isStreamedValue as bool?) ?? false; + } + + /// + /// Mark the request message as configured for a streamed / buffered response. + /// + /// + /// The HTTP request message. + /// + /// + /// If true, the request message is configured for a streamed response; otherwise, it is configured for a buffered response. + /// + /// + /// The HTTP request message (enables inline use). + /// + public static HttpRequestMessage MarkAsStreamed(this HttpRequestMessage message, bool isStreamed = true) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + // TODO: Switch to HttpRequestOptions once we drop netstandard2.1 support. +#pragma warning disable CS0618 // Type or member is obsolete + message.Properties[MessageProperties.IsStreamed] = isStreamed; +#pragma warning restore CS0618 // Type or member is obsolete + + return message; + } + } +} \ No newline at end of file diff --git a/src/KubeClient/Http/MessageProperties.cs b/src/KubeClient/Http/MessageProperties.cs new file mode 100644 index 0000000..a8174af --- /dev/null +++ b/src/KubeClient/Http/MessageProperties.cs @@ -0,0 +1,30 @@ +namespace KubeClient.Http +{ + // TODO: Switch to HttpRequestOptions once we drop netstandard2.1 support. + + /// + /// The names of well-known HttpRequestMessage / HttpResponseMessage properties. + /// + public static class MessageProperties + { + /// + /// The prefix for HTTPlease property names. + /// + static readonly string Prefix = "HTTPlease."; + + /// + /// The that created the message. + /// + public static readonly string Request = Prefix + "Request"; + + /// + /// The message's collection of content formatters. + /// + public static readonly string ContentFormatters = Prefix + "ContentFormatters"; + + /// + /// Is the request configured for a streamed response? + /// + public static readonly string IsStreamed = Prefix + "IsStreamed"; + } +} diff --git a/src/KubeClient/Http/OtherHttpMethods.cs b/src/KubeClient/Http/OtherHttpMethods.cs new file mode 100644 index 0000000..ea1e542 --- /dev/null +++ b/src/KubeClient/Http/OtherHttpMethods.cs @@ -0,0 +1,15 @@ +using System.Net.Http; + +namespace KubeClient.Http +{ + /// + /// Additional standard HTTP methods. + /// + public static class OtherHttpMethods + { + /// + /// The HTTP PATCH method. + /// + public static readonly HttpMethod Patch = new HttpMethod("PATCH"); + } +} diff --git a/src/KubeClient/Http/RequestActions.cs b/src/KubeClient/Http/RequestActions.cs new file mode 100644 index 0000000..6dc5358 --- /dev/null +++ b/src/KubeClient/Http/RequestActions.cs @@ -0,0 +1,26 @@ +using System.Net.Http; + +namespace KubeClient.Http +{ + /// + /// Delegate that performs configuration of an outgoing HTTP request message. + /// + /// + /// The outgoing request message. + /// + public delegate void RequestAction(HttpRequestMessage requestMessage); + + /// + /// Delegate that performs configuration of an outgoing HTTP request message. + /// + /// + /// The type of object used by the request when resolving deferred parameters. + /// + /// + /// The outgoing request message. + /// + /// + /// The object used by the request when resolving deferred parameters. + /// + public delegate void RequestAction(HttpRequestMessage requestMessage, TContext context); +} diff --git a/src/KubeClient/Http/RequestExtensions.Headers.cs b/src/KubeClient/Http/RequestExtensions.Headers.cs new file mode 100644 index 0000000..682f3b6 --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.Headers.cs @@ -0,0 +1,274 @@ +using System; +using System.Net.Http.Headers; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// / extension methods for HTTP headers. + /// + public static partial class RequestExtensions + { + /// + /// Create a copy of the request that adds a header to each request. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// The header value. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeader(this HttpRequest request, string headerName, string headerValue, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(headerName)); + + if (headerValue == null) + throw new ArgumentNullException(nameof(headerValue)); + + return request.WithHeaderFromProvider(headerName, + ValueProvider.FromConstantValue(headerValue), + ensureQuoted + ); + } + + /// + /// Create a copy of the request that adds a header with its value obtained from the specified delegate. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// A delegate that returns the header value for each request. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeader(this HttpRequest request, string headerName, Func getValue, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'headerName'.", nameof(headerName)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeaderFromProvider(headerName, + ValueProvider.FromFunction(getValue).Convert().ValueToString(), + ensureQuoted + ); + } + + /// + /// Create a copy of the request, but with the specified media type added to the "Accept" header. + /// + /// + /// The HTTP request. + /// + /// + /// The media-type name. + /// + /// + /// An optional media-type quality. + /// + /// + /// The new . + /// + public static HttpRequest AcceptMediaType(this HttpRequest request, string mediaType, double? quality = null) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(mediaType)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'mediaType'.", nameof(mediaType)); + + MediaTypeWithQualityHeaderValue mediaTypeHeader = + quality.HasValue ? + new MediaTypeWithQualityHeaderValue(mediaType, quality.Value) + : + new MediaTypeWithQualityHeaderValue(mediaType); + + return request.WithRequestAction(requestMessage => + { + requestMessage.Headers.Accept.Add(mediaTypeHeader); + }); + } + + /// + /// Create a copy of the request, but with no media types in the "Accept" header. + /// + /// + /// The HTTP request. + /// + /// + /// The new . + /// + public static HttpRequest AcceptNoMediaTypes(this HttpRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + return request.WithRequestAction(requestMessage => + { + requestMessage.Headers.Accept.Clear(); + }); + } + + /// + /// Create a copy of the request that adds an "If-Match" header to each request. + /// + /// + /// The HTTP request. + /// + /// + /// The header value. + /// + /// + /// The new . + /// + public static HttpRequest WithIfMatchHeader(this HttpRequest request, string headerValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (headerValue == null) + throw new ArgumentNullException(nameof(headerValue)); + + return request.WithHeader("If-Match", () => headerValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-Match" header with its value obtained from the specified delegate. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that returns the header value for each request. + /// + /// + /// The new . + /// + public static HttpRequest WithIfMatchHeader(this HttpRequest request, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeader("If-Match", getValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-None-Match" header to each request. + /// + /// + /// The HTTP request. + /// + /// + /// The header value. + /// + /// + /// The new . + /// + public static HttpRequest WithIfNoneMatchHeader(this HttpRequest request, string headerValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (headerValue == null) + throw new ArgumentNullException(nameof(headerValue)); + + return request.WithHeader("If-None-Match", () => headerValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-None-Match" header with its value obtained from the specified delegate. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that returns the header value for each request. + /// + /// + /// The new . + /// + public static HttpRequest WithIfNoneMatchHeader(this HttpRequest request, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeader("If-None-Match", getValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds a header to each request. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// The header value provider. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeaderFromProvider(this HttpRequest request, string headerName, IValueProvider valueProvider, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(headerName)); + + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return request.WithRequestAction((requestMessage, context) => + { + requestMessage.Headers.Remove(headerName); + + string headerValue = valueProvider.Get(context); + if (headerValue == null) + return; + + if (ensureQuoted) + headerValue = EnsureQuoted(headerValue); + + requestMessage.Headers.Add(headerName, headerValue); + }); + } + } +} diff --git a/src/KubeClient/Http/RequestExtensions.Helpers.cs b/src/KubeClient/Http/RequestExtensions.Helpers.cs new file mode 100644 index 0000000..86c5d12 --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.Helpers.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// Helper methods for / extensions. + /// + public static partial class RequestExtensions + { + /// + /// Configure the request URI (and template status) in the request properties. + /// + /// + /// The request properties to modify. + /// + /// + /// The request URI. + /// + static void SetUri(this IDictionary requestProperties, Uri requestUri) + { + if (requestProperties == null) + throw new ArgumentNullException(nameof(requestProperties)); + + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + requestProperties[nameof(IHttpRequest.Uri)] = requestUri; + requestProperties[nameof(IHttpRequest.IsUriTemplate)] = UriTemplate.IsTemplate(requestUri); + } + + /// + /// Ensure that the specified string is surrounted by quotes. + /// + /// + /// The string to examine. + /// + /// + /// The string, with quotes prepended / appended as required. + /// + /// + /// Some HTTP headers (such as If-Match) require their values to be quoted. + /// + static string EnsureQuoted(string str) + { + if (str == null) + throw new ArgumentNullException(nameof(str)); + + if (str.Length == 0) + return "\"\""; + + StringBuilder quotedStringBuilder = new StringBuilder(str); + + if (quotedStringBuilder[0] != '\"') + quotedStringBuilder.Insert(0, '\"'); + + if (quotedStringBuilder[quotedStringBuilder.Length - 1] != '\"') + quotedStringBuilder.Append('\"'); + + return quotedStringBuilder.ToString(); + } + + /// + /// Convert the specified object's properties to deferred parameters. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The type of object whose properties will form the parameters. + /// + /// + /// The object whose properties will form the parameters. + /// + /// + /// A sequence of key / value pairs representing the parameters. + /// + static IEnumerable>> CreateDeferredParameters(this TParameters parameters) + { + if (Equals(parameters, null)) + throw new ArgumentNullException(nameof(parameters)); + + // TODO: Refactor PropertyInfo retrieval logic (move it out to an extension method). + + // Yes yes yes, reflection might be "slow", but it's still blazingly fast compared to making a request over the network. + foreach (PropertyInfo property in typeof(TParameters).GetTypeInfo().DeclaredProperties) + { + // Ignore write-only properties. + if (!property.CanRead) + continue; + + // Public instance properties only. + if (!property.GetMethod.IsPublic || property.GetMethod.IsStatic) + continue; + + yield return new KeyValuePair>( + property.Name, + ValueProvider.FromSelector( + context => property.GetValue(parameters) + ) + .Convert().ValueToString() + ); + } + } + } +} diff --git a/src/KubeClient/Http/RequestExtensions.QueryParameters.cs b/src/KubeClient/Http/RequestExtensions.QueryParameters.cs new file mode 100644 index 0000000..a5bad38 --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.QueryParameters.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// / extension methods for query parameters. + /// + public static partial class RequestExtensions + { + /// + /// Create a copy of the request builder with the specified request URI query parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter data-type. + /// + /// + /// The parameter name. + /// + /// + /// The parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameter(this HttpRequest request, string name, TParameter value) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + return request.WithQueryParameterFromProvider(name, + ValueProvider.FromConstantValue(value?.ToString()) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI query parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter data-type. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that returns the parameter value (cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameter(this HttpRequest request, string name, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithQueryParameterFromProvider( + name, + ValueProvider.FromFunction(getValue) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI query parameter. + /// + /// + /// The parameter data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that, given the current context, returns the parameter value (cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameterFromProvider(this HttpRequest request, string name, IValueProvider valueProvider) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = request.QueryParameters.SetItem( + key: name, + value: valueProvider.Convert().ValueToString() + ); + }); + } + + /// + /// Create a copy of the request, but with query parameters from the specified object's properties. + /// + /// + /// The type of object whose properties will form the parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The object whose properties will form the parameters. + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameters(this HttpRequest request, TParameters parameters) + { + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + return request.WithQueryParametersFromProviders( + CreateDeferredParameters(parameters) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI query parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A sequence of 0 or more key / value pairs representing the query parameters (values cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParametersFromProviders(this HttpRequest request, IEnumerable>> queryParameters) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (queryParameters == null) + throw new ArgumentNullException(nameof(queryParameters)); + + bool modified = false; + ImmutableDictionary>.Builder queryParametersBuilder = request.QueryParameters.ToBuilder(); + foreach (KeyValuePair> queryParameter in queryParameters) + { + if (queryParameter.Value == null) + { + throw new ArgumentException( + String.Format( + "Query parameter '{0}' has a null getter; this is not supported.", + queryParameter.Key + ), + nameof(queryParameters) + ); + } + + queryParametersBuilder[queryParameter.Key] = queryParameter.Value; + modified = true; + } + + if (!modified) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = queryParametersBuilder.ToImmutable(); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI query parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// The new . + /// + public static HttpRequest WithoutQueryParameter(this HttpRequest request, string name) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (!request.QueryParameters.ContainsKey(name)) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = request.QueryParameters.Remove(name); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI query parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter names. + /// + /// + /// The new . + /// + public static HttpRequest WithoutQueryParameters(this HttpRequest request, IEnumerable names) + { + if (names == null) + throw new ArgumentNullException(nameof(names)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = request.QueryParameters.RemoveRange(names); + }); + } + } +} diff --git a/src/KubeClient/Http/RequestExtensions.RequestActions.cs b/src/KubeClient/Http/RequestExtensions.RequestActions.cs new file mode 100644 index 0000000..8846af2 --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.RequestActions.cs @@ -0,0 +1,130 @@ +using System; +using System.Linq; + +namespace KubeClient.Http +{ + /// + /// / extension methods for request-configuration actions. + /// + public static partial class RequestExtensions + { + /// + /// Create a copy of the request with the specified request-configuration action. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, RequestAction requestAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestAction == null) + throw new ArgumentNullException(nameof(requestAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.Add( + (message, context) => requestAction(message) + ); + }); + } + + /// + /// Create a copy of the request with the specified request-configuration action. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, RequestAction requestAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestAction == null) + throw new ArgumentNullException(nameof(requestAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.Add(requestAction); + }); + } + + /// + /// Create a copy of the request with the specified request-configuration actions. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, params RequestAction[] requestActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestActions == null) + throw new ArgumentNullException(nameof(requestActions)); + + if (requestActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.AddRange( + requestActions.Select(requestAction => + { + RequestAction requestActionWithContext = (message, context) => requestAction(message); + + return requestActionWithContext; + }) + ); + }); + } + + /// + /// Create a copy of the request with the specified request-configuration actions. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, params RequestAction[] requestActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestActions == null) + throw new ArgumentNullException(nameof(requestActions)); + + if (requestActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.AddRange(requestActions); + }); + } + } +} diff --git a/src/KubeClient/Http/RequestExtensions.RequestUri.cs b/src/KubeClient/Http/RequestExtensions.RequestUri.cs new file mode 100644 index 0000000..ed21567 --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.RequestUri.cs @@ -0,0 +1,166 @@ +using System; + +namespace KubeClient.Http +{ + using Utilities; + + /// + /// / extension methods for request URIs. + /// + public static partial class RequestExtensions + { + /// + /// Ensure that the has an absolute URI. + /// + /// + /// The request's absolute URI. + /// + /// + /// The request has a relative URI. + /// + public static bool HasAbsoluteUri(this IHttpRequest httpRequest) + { + if (httpRequest == null) + throw new ArgumentNullException(nameof(httpRequest)); + + return httpRequest.Uri.IsAbsoluteUri; + } + + /// + /// Ensure that the has an absolute URI. + /// + /// + /// The request's absolute URI. + /// + /// + /// The request has a relative URI. + /// + public static Uri EnsureAbsoluteUri(this IHttpRequest httpRequest) + { + if (httpRequest == null) + throw new ArgumentNullException(nameof(httpRequest)); + + Uri requestUri = httpRequest.Uri; + if (requestUri.IsAbsoluteUri) + return requestUri; + + throw new InvalidOperationException("The HTTP request does not have an absolute URI."); + } + + /// + /// Create a copy of the request with the specified base URI. + /// + /// + /// The request. + /// + /// + /// The request base URI (must be absolute). + /// + /// + /// The new . + /// + /// + /// The request already has an absolute URI. + /// + public static HttpRequest WithBaseUri(this HttpRequest request, Uri baseUri) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (baseUri == null) + throw new ArgumentNullException(nameof(baseUri)); + + if (!baseUri.IsAbsoluteUri) + throw new ArgumentException("The supplied base URI is not an absolute URI.", nameof(baseUri)); + + if (request.Uri.IsAbsoluteUri) + throw new InvalidOperationException("The request already has an absolute URI."); + + return request.Clone(properties => + { + properties.SetUri( + baseUri.AppendRelativeUri(request.Uri) + ); + }); + } + + /// + /// Create a copy of the request with the specified request URI. + /// + /// + /// The request. + /// + /// + /// The new request URI. + /// + /// + /// The new . + /// + public static HttpRequest WithUri(this HttpRequest request, Uri requestUri) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + return request.Clone(properties => + { + properties.SetUri(requestUri); + }); + } + + /// + /// Create a copy of the request with the specified request URI appended to its existing URI. + /// + /// + /// The request. + /// + /// + /// The relative request URI. + /// + /// + /// The new . + /// + public static HttpRequest WithRelativeUri(this HttpRequest request, string relativeUri) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(relativeUri)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'relativeUri'.", nameof(relativeUri)); + + return request.WithRelativeUri( + new Uri(relativeUri, UriKind.Relative) + ); + } + + /// + /// Create a copy of the request with the specified request URI appended to its existing URI. + /// + /// + /// The request. + /// + /// + /// The relative request URI. + /// + /// + /// The new . + /// + public static HttpRequest WithRelativeUri(this HttpRequest request, Uri relativeUri) + { + if (relativeUri == null) + throw new ArgumentNullException(nameof(relativeUri)); + + if (relativeUri.IsAbsoluteUri) + throw new ArgumentException("The specified URI is not a relative URI.", nameof(relativeUri)); + + return request.Clone(properties => + { + properties.SetUri( + request.Uri.AppendRelativeUri(relativeUri) + ); + }); + } + } +} diff --git a/src/KubeClient/Http/RequestExtensions.ResponseActions.cs b/src/KubeClient/Http/RequestExtensions.ResponseActions.cs new file mode 100644 index 0000000..f315476 --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.ResponseActions.cs @@ -0,0 +1,130 @@ +using System; +using System.Linq; + +namespace KubeClient.Http +{ + /// + /// / extension methods for response-processing actions. + /// + public static partial class RequestExtensions + { + /// + /// Create a copy of the request with the specified response-processing action. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, ResponseAction responseAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseAction == null) + throw new ArgumentNullException(nameof(responseAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.Add( + (message, context) => responseAction(message) + ); + }); + } + + /// + /// Create a copy of the request with the specified response-processing action. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, ResponseAction responseAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseAction == null) + throw new ArgumentNullException(nameof(responseAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.Add(responseAction); + }); + } + + /// + /// Create a copy of the request with the specified response-processing actions. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, params ResponseAction[] responseActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseActions == null) + throw new ArgumentNullException(nameof(responseActions)); + + if (responseActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.AddRange( + responseActions.Select(responseAction => + { + ResponseAction responseActionWithContext = (message, context) => responseAction(message); + + return responseActionWithContext; + }) + ); + }); + } + + /// + /// Create a copy of the request with the specified response-processing actions. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, params ResponseAction[] responseActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseActions == null) + throw new ArgumentNullException(nameof(responseActions)); + + if (responseActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.AddRange(responseActions); + }); + } + } +} diff --git a/src/KubeClient/Http/RequestExtensions.TemplateParameters.cs b/src/KubeClient/Http/RequestExtensions.TemplateParameters.cs new file mode 100644 index 0000000..86fc06e --- /dev/null +++ b/src/KubeClient/Http/RequestExtensions.TemplateParameters.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// / extension methods for template parameters. + /// + public static partial class RequestExtensions + { + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter data-type. + /// + /// + /// The parameter name. + /// + /// + /// The parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameter(this HttpRequest request, string name, TParameter value) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + return request.WithTemplateParameterFromProvider(name, + ValueProvider.FromConstantValue(value?.ToString()) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter data-type. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that returns the parameter value (cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameter(this HttpRequest request, string name, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithTemplateParameterFromProvider(name, + ValueProvider.FromFunction(getValue).Convert().ValueToString() + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter data-type. + /// + /// + /// The parameter name. + /// + /// + /// A value provider that, given the current context, returns the parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameterFromProvider(this HttpRequest request, string name, IValueProvider valueProvider) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = request.TemplateParameters.SetItem( + key: name, + value: valueProvider.Convert().ValueToString() + ); + }); + } + + /// + /// Create a copy of the request, but with template parameters from the specified object's properties. + /// + /// + /// The type of object whose properties will form the parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The object whose properties will form the parameters. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameters(this HttpRequest request, TParameters parameters) + { + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + return request.WithTemplateParametersFromProviders( + CreateDeferredParameters(parameters) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A sequence of 0 or more key / value pairs representing the template parameters (values cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParametersFromProviders(this HttpRequest request, IEnumerable>> templateParameters) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (templateParameters == null) + throw new ArgumentNullException(nameof(templateParameters)); + + bool modified = false; + ImmutableDictionary>.Builder templateParametersBuilder = request.TemplateParameters.ToBuilder(); + foreach (KeyValuePair> templateParameter in templateParameters) + { + if (templateParameter.Value == null) + { + throw new ArgumentException( + String.Format( + "Template parameter '{0}' has a null getter; this is not supported.", + templateParameter.Key + ), + nameof(templateParameters) + ); + } + + templateParametersBuilder[templateParameter.Key] = templateParameter.Value; + modified = true; + } + + if (!modified) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = templateParametersBuilder.ToImmutable(); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI template parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// The new . + /// + public static HttpRequest WithoutTemplateParameter(this HttpRequest request, string name) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (!request.TemplateParameters.ContainsKey(name)) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = request.TemplateParameters.Remove(name); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter names. + /// + /// + /// The new . + /// + public static HttpRequest WithoutTemplateParameters(this HttpRequest request, IEnumerable names) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (names == null) + throw new ArgumentNullException(nameof(names)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = request.TemplateParameters.RemoveRange(names); + }); + } + } +} diff --git a/src/KubeClient/Http/RequestHeaderExtensions.cs b/src/KubeClient/Http/RequestHeaderExtensions.cs new file mode 100644 index 0000000..b0ca9dc --- /dev/null +++ b/src/KubeClient/Http/RequestHeaderExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Headers; + +namespace KubeClient.Http +{ + /// + /// Extension method for + /// + public static class RequestHeaderExtensions + { + /// + /// Retrieve the value of an optional HTTP request header. + /// + /// + /// The HTTP request headers to examine. + /// + /// + /// The name of the target header. + /// + /// + /// The header value, or null if the header is not present (or an string if the header is present but has no value). + /// + public static string GetOptionalHeaderValue(this HttpRequestHeaders requestHeaders, string headerName) + { + if (requestHeaders == null) + throw new ArgumentNullException(nameof(requestHeaders)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'headerName'.", nameof(headerName)); + + IEnumerable headerValues; + if (!requestHeaders.TryGetValues(headerName, out headerValues)) + return null; + + return + headerValues.DefaultIfEmpty( + String.Empty + ) + .FirstOrDefault(); + } + } +} diff --git a/src/KubeClient/Http/ResponseActions.cs b/src/KubeClient/Http/ResponseActions.cs new file mode 100644 index 0000000..30c1c2f --- /dev/null +++ b/src/KubeClient/Http/ResponseActions.cs @@ -0,0 +1,26 @@ +using System.Net.Http; + +namespace KubeClient.Http +{ + /// + /// Delegate that performs processing of an incoming HTTP response message. + /// + /// + /// The incoming response message. + /// + public delegate void ResponseAction(HttpResponseMessage responseMessage); + + /// + /// Delegate that performs processing of an incoming HTTP response message. + /// + /// + /// The type of object used by the response when resolving deferred parameters. + /// + /// + /// The incoming response message. + /// + /// + /// The object used by the response when resolving deferred parameters. + /// + public delegate void ResponseAction(HttpResponseMessage responseMessage, TContext context); +} diff --git a/src/KubeClient/Http/Templates/ITemplateEvaluationContext.cs b/src/KubeClient/Http/Templates/ITemplateEvaluationContext.cs new file mode 100644 index 0000000..18fbe7d --- /dev/null +++ b/src/KubeClient/Http/Templates/ITemplateEvaluationContext.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; + +namespace KubeClient.Http.Templates +{ + /// + /// Represents the evaluation context for a URI template. + /// + interface ITemplateEvaluationContext + { + /// + /// Determine whether the specified parameter is defined. + /// + /// + /// The parameter name. + /// + /// + /// true, if the parameter is defined; otherwise, false. + /// + bool IsParameterDefined(string parameterName); + + /// + /// The value of the specified template parameter + /// + /// + /// The name of the template parameter. + /// + /// + /// Is the parameter optional? If so, return null if it is not present, rather than throwing an exception. + /// + /// Default is true. + /// + /// + /// The parameter value, or null. + /// + /// + /// is null, empty, or entirely composed of whitespace. + /// + /// + /// The parameter is not , and is not preset. + /// + string this[string parameterName, bool isOptional = false] + { + get; + } + } +} diff --git a/src/KubeClient/Http/Templates/LiteralQuerySegment.cs b/src/KubeClient/Http/Templates/LiteralQuerySegment.cs new file mode 100644 index 0000000..2d6e268 --- /dev/null +++ b/src/KubeClient/Http/Templates/LiteralQuerySegment.cs @@ -0,0 +1,62 @@ +using System; + +namespace KubeClient.Http.Templates +{ + /// + /// A template segment that represents a literal query parameter (i.e. one that has a constant value). + /// + sealed class LiteralQuerySegment + : QuerySegment + { + /// + /// The value for the query parameter that the segment represents. + /// + readonly string _queryParameterValue; + + /// + /// Create a new literal query segment. + /// + /// + /// The name of the query parameter that the segment represents. + /// + /// + /// The value for the query parameter that the segment represents. + /// + public LiteralQuerySegment(string queryParameterName, string queryParameterValue) + : base(queryParameterName) + { + if (String.IsNullOrWhiteSpace(queryParameterValue)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'value'.", nameof(queryParameterValue)); + + _queryParameterValue = queryParameterValue; + } + + /// + /// The value for the query parameter that the segment represents. + /// + public string QueryParameterValue + { + get + { + return _queryParameterValue; + } + } + + /// + /// Get the value of the segment (if any). + /// + /// + /// The current template evaluation context. + /// + /// + /// The segment value, or null if the segment has no value. + /// + public override string GetValue(ITemplateEvaluationContext evaluationContext) + { + if (evaluationContext == null) + throw new ArgumentNullException(nameof(evaluationContext)); + + return _queryParameterValue; + } + } +} diff --git a/src/KubeClient/Http/Templates/LiteralUriSegment.cs b/src/KubeClient/Http/Templates/LiteralUriSegment.cs new file mode 100644 index 0000000..220b4b9 --- /dev/null +++ b/src/KubeClient/Http/Templates/LiteralUriSegment.cs @@ -0,0 +1,62 @@ +using System; + +namespace KubeClient.Http.Templates +{ + /// + /// Represents a literal URI segment (i.e. one that has a constant value). + /// + sealed class LiteralUriSegment + : UriSegment + { + /// + /// The segment value; + /// + readonly string _value; + + /// + /// Create a new literal URI segment. + /// + /// + /// The segment value. + /// + /// + /// Does the segment represent a directory (i.e. have a trailing slash?). + /// + public LiteralUriSegment(string value, bool isDirectory) + : base(isDirectory) + { + if (String.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'value'.", nameof(value)); + + _value = value; + } + + /// + /// The segment value; + /// + public string Value + { + get + { + return _value; + } + } + + /// + /// Get the value of the segment (if any). + /// + /// + /// The current template evaluation context. + /// + /// + /// The segment value, or null if the segment is missing. + /// + public override string GetValue(ITemplateEvaluationContext evaluationContext) + { + if (evaluationContext == null) + throw new ArgumentNullException(nameof(evaluationContext)); + + return _value; + } + } +} diff --git a/src/KubeClient/Http/Templates/ParameterizedQuerySegment.cs b/src/KubeClient/Http/Templates/ParameterizedQuerySegment.cs new file mode 100644 index 0000000..e70db94 --- /dev/null +++ b/src/KubeClient/Http/Templates/ParameterizedQuerySegment.cs @@ -0,0 +1,93 @@ +using System; + +namespace KubeClient.Http.Templates +{ + /// + /// A template segment that represents a query parameter whose value comes from a template parameter. + /// + sealed class ParameterizedQuerySegment + : QuerySegment + { + /// + /// The name of the template parameter whose value becomes the query parameter. + /// + readonly string _templateParameterName; + + /// + /// Is the segment optional? + /// + /// + /// If true, then the query parameter will be omitted if its associated template variable is not defined. + /// + readonly bool _isOptional; + + /// + /// Create a new literal query segment. + /// + /// + /// The name of the query parameter that the segment represents. + /// + /// + /// The value for the query parameter that the segment represents. + /// + /// + /// Is the segment optional? + /// + public ParameterizedQuerySegment(string queryParameterName, string templateParameterName, bool isOptional = false) + : base(queryParameterName) + { + if (String.IsNullOrWhiteSpace(templateParameterName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'value'.", nameof(templateParameterName)); + + _templateParameterName = templateParameterName; + _isOptional = isOptional; + } + + /// + /// The name of the template parameter whose value becomes the query parameter. + /// + public string TemplateParameterName + { + get + { + return _templateParameterName; + } + } + + /// + /// Is the segment optional? + /// + /// + /// If true, then the query parameter will be omitted if its associated template variable is not defined. + /// + public bool IsOptional + { + get + { + return _isOptional; + } + } + + /// + /// Does the segment have a parameterised (non-constant) value? + /// + public override bool IsParameterized => true; + + /// + /// Get the value of the segment (if any). + /// + /// + /// The current template evaluation context. + /// + /// + /// The segment value, or null if the segment has no value. + /// + public override string GetValue(ITemplateEvaluationContext evaluationContext) + { + if (evaluationContext == null) + throw new ArgumentNullException(nameof(evaluationContext)); + + return evaluationContext[_templateParameterName, _isOptional]; + } + } +} diff --git a/src/KubeClient/Http/Templates/ParameterizedUriSegment.cs b/src/KubeClient/Http/Templates/ParameterizedUriSegment.cs new file mode 100644 index 0000000..dba0b5b --- /dev/null +++ b/src/KubeClient/Http/Templates/ParameterizedUriSegment.cs @@ -0,0 +1,95 @@ +using System; + +namespace KubeClient.Http.Templates +{ + /// + /// Represents a literal URI segment (i.e. one that has a constant value). + /// + sealed class ParameterizedUriSegment + : UriSegment + { + /// + /// The name of the parameter from which the URI segment obtains its value. + /// + readonly string _templateParameterName; + + /// + /// Is the segment optional? + /// + /// + /// If true, then the segment is not rendered when its associated parameter is missing. + /// + readonly bool _isOptional; + + /// + /// Create a new literal URI segment. + /// + /// + /// The name of the parameter from which the URI segment obtains its value. + /// + /// + /// Does the segment represent a directory (i.e. have a trailing slash?). + /// + /// + /// Is the segment optional? + /// + /// Default is false. + /// + public ParameterizedUriSegment(string templateParameterName, bool isDirectory, bool isOptional = false) + : base(isDirectory) + { + if (String.IsNullOrWhiteSpace(templateParameterName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'value'.", nameof(templateParameterName)); + + _templateParameterName = templateParameterName; + _isOptional = isOptional; + } + + /// + /// The name of the parameter from which the URI segment obtains its value. + /// + public string TemplateParameterName + { + get + { + return _templateParameterName; + } + } + + /// + /// Is the segment optional? + /// + /// + /// If true, then the segment is not rendered when its associated parameter is missing. + /// + public bool IsOptional + { + get + { + return _isOptional; + } + } + + /// + /// Does the segment have a parameterised (non-constant) value? + /// + public override bool IsParameterized => true; + + /// + /// Get the value of the segment (if any). + /// + /// + /// The current template evaluation context. + /// + /// + /// The segment value, or null if the segment is missing. + /// + public override string GetValue(ITemplateEvaluationContext evaluationContext) + { + if (evaluationContext == null) + throw new ArgumentNullException(nameof(evaluationContext)); + + return evaluationContext[_templateParameterName, _isOptional]; + } + } +} diff --git a/src/KubeClient/Http/Templates/QuerySegment.cs b/src/KubeClient/Http/Templates/QuerySegment.cs new file mode 100644 index 0000000..cdb7fe6 --- /dev/null +++ b/src/KubeClient/Http/Templates/QuerySegment.cs @@ -0,0 +1,41 @@ +using System; + +namespace KubeClient.Http.Templates +{ + /// + /// The base class for template segments that represent components of a URI's query. + /// + abstract class QuerySegment + : TemplateSegment + { + /// + /// The name of the query parameter that the segment represents. + /// + readonly string _queryParameterName; + + /// + /// Create a new query segment. + /// + /// + /// The name of the query parameter that the segment represents. + /// + protected QuerySegment(string queryParameterName) + { + if (String.IsNullOrWhiteSpace(queryParameterName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'queryParameterName'.", nameof(queryParameterName)); + + _queryParameterName = queryParameterName; + } + + /// + /// The name of the query parameter that the segment represents. + /// + public string QueryParameterName + { + get + { + return _queryParameterName; + } + } + } +} diff --git a/src/KubeClient/Http/Templates/RootUriSegment.cs b/src/KubeClient/Http/Templates/RootUriSegment.cs new file mode 100644 index 0000000..c174a1a --- /dev/null +++ b/src/KubeClient/Http/Templates/RootUriSegment.cs @@ -0,0 +1,41 @@ +using System; + +namespace KubeClient.Http.Templates +{ + /// + /// A literal URI segment representing the root folder ("/"). + /// + sealed class RootUriSegment + : UriSegment + { + /// + /// The singleton instance of the root URI segment. + /// + public static readonly RootUriSegment Instance = new RootUriSegment(); + + /// + /// Create a new literal URI segment. + /// + RootUriSegment() + : base(isDirectory: true) + { + } + + /// + /// Get the value of the segment (if any). + /// + /// + /// The current template evaluation context. + /// + /// + /// The segment value, or null if the segment is missing. + /// + public override string GetValue(ITemplateEvaluationContext evaluationContext) + { + if (evaluationContext == null) + throw new ArgumentNullException(nameof(evaluationContext)); + + return String.Empty; + } + } +} diff --git a/src/KubeClient/Http/Templates/TemplateEvaluationContext.cs b/src/KubeClient/Http/Templates/TemplateEvaluationContext.cs new file mode 100644 index 0000000..1475d68 --- /dev/null +++ b/src/KubeClient/Http/Templates/TemplateEvaluationContext.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace KubeClient.Http.Templates +{ + /// + /// The default evaluation context for a URI template. + /// + sealed class TemplateEvaluationContext + : ITemplateEvaluationContext + { + /// + /// The template parameters. + /// + readonly Dictionary _templateParameters = new Dictionary(); + + /// + /// Create a new template evaluation context. + /// + public TemplateEvaluationContext() + { + } + + /// + /// Create a new template evaluation context. + /// + /// + /// A dictionary of template parameters (and their values) used to populate the evaluation context. + /// + public TemplateEvaluationContext(IDictionary templateParameters) + { + if (templateParameters == null) + throw new ArgumentNullException(nameof(templateParameters)); + + foreach (KeyValuePair templateParameter in templateParameters) + _templateParameters[templateParameter.Key] = templateParameter.Value; + } + + /// + /// The template parameters. + /// + public Dictionary TemplateParameters + { + get + { + return _templateParameters; + } + } + + /// + /// Determine whether the specified parameter is defined. + /// + /// + /// The parameter name. + /// + /// + /// true, if the parameter is defined; otherwise, false. + /// + public bool IsParameterDefined(string parameterName) + { + if (String.IsNullOrWhiteSpace(parameterName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'parameterName'.", nameof(parameterName)); + + return _templateParameters.ContainsKey(parameterName); + } + + /// + /// The value of the specified template parameter + /// + /// + /// The name of the template parameter. + /// + /// + /// Is the parameter optional? If so, return null if it is not present, rather than throwing an exception. + /// + /// Default is true. + /// + /// + /// The parameter value, or null. + /// + /// + /// is null, empty, or entirely composed of whitespace. + /// + /// + /// The parameter is not , and is not preset. + /// + public string this[string parameterName, bool isOptional] + { + get + { + if (String.IsNullOrWhiteSpace(parameterName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'parameterName'.", nameof(parameterName)); + + string parameterValue; + if (!_templateParameters.TryGetValue(parameterName, out parameterValue)) + { + if (!isOptional) + throw new UriTemplateException($"Required template parameter '{parameterName}' is not defined."); + } + + return parameterValue; + } + } + } +} diff --git a/src/KubeClient/Http/Templates/TemplateSegment.cs b/src/KubeClient/Http/Templates/TemplateSegment.cs new file mode 100644 index 0000000..176974e --- /dev/null +++ b/src/KubeClient/Http/Templates/TemplateSegment.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace KubeClient.Http.Templates +{ + /// + /// The base class for the segments that comprise a URI template. + /// + abstract class TemplateSegment + { + /// + /// The regular expression used to match variables. + /// + static readonly Regex VariableRegex = new Regex( + @"\{(?\w+)(?\?)?\}\/?", + RegexOptions.Compiled | RegexOptions.Singleline + ); + + /// + /// Create a new URI template segment. + /// + protected TemplateSegment() + { + } + + /// + /// Does the segment have a parameterised (non-constant) value? + /// + public virtual bool IsParameterized => true; + + /// + /// Get the value of the segment (if any). + /// + /// + /// The current template evaluation context. + /// + /// + /// The segment value, or null if the segment has no value. + /// + public abstract string GetValue(ITemplateEvaluationContext evaluationContext); + + /// + /// Parse the specified URI into template segments. + /// + /// + /// The URI to parse. + /// + /// + /// The template segments. + /// + public static IReadOnlyList Parse(string template) + { + if (template == null) + throw new ArgumentNullException(nameof(template)); + + List segments = new List(); + + try + { + Uri templateUri = new Uri( + new Uri("http://localhost/"), + template.Replace("?}", "%3F}") // Special case for '?' because it messes with Uri's parser. + ); + segments.AddRange( + ParsePathSegments(templateUri) + ); + segments.AddRange( + ParseQuerySegments(templateUri) + ); + } + catch (Exception eParse) + { + throw new UriTemplateException(eParse, $"'{template}' is not a valid URI template."); + } + + return segments; + } + + /// + /// Parse URI segments from the specified template. + /// + /// + /// The URI template. + /// + /// + /// A sequence of 0 or more URI segments. + /// + static IEnumerable ParsePathSegments(Uri template) + { + if (template == null) + throw new ArgumentNullException(nameof(template)); + + bool haveRoot = false; + bool isLastSegmentDirectory = template.AbsolutePath[template.AbsolutePath.Length - 1] == '/'; + + string[] pathSegments = + template.AbsolutePath + .Split('/') + .Select( + segment => Uri.UnescapeDataString(segment) + ) + .ToArray(); + + int lastSegmentIndex = pathSegments.Length - 1; + for (int segmentIndex = 0; segmentIndex < pathSegments.Length; segmentIndex++) + { + string pathSegment = pathSegments[segmentIndex]; + if (pathSegment != String.Empty) + { + bool isDirectory = isLastSegmentDirectory || segmentIndex < lastSegmentIndex; + + Match variableMatch = VariableRegex.Match(pathSegment); + if (variableMatch.Success) + { + string templateParameterName = variableMatch.Groups["VariableName"].Value; + if (String.IsNullOrWhiteSpace(templateParameterName)) + yield return new LiteralUriSegment(pathSegment, isDirectory); + + bool isOptional = variableMatch.Groups["VariableIsOptional"].Value.Length > 0; + + yield return new ParameterizedUriSegment(templateParameterName, isDirectory, isOptional); + } + else + yield return new LiteralUriSegment(pathSegment, isDirectory); + } + else + { + if (haveRoot) + continue; + + haveRoot = true; + + yield return RootUriSegment.Instance; + } + } + } + + /// + /// Parse query segments from the specified template. + /// + /// + /// The URI template. + /// + /// + /// A sequence of 0 or more query segments. + /// + static IEnumerable ParseQuerySegments(Uri template) + { + if (template == null) + throw new ArgumentNullException(nameof(template)); + + if (template.Query == String.Empty) + yield break; + + string[] queryParameters = + template.Query.Substring(1).Split( + separator: new char[] + { + '&' + }, + options: StringSplitOptions.RemoveEmptyEntries + + ); + + foreach (string queryParameter in queryParameters) + { + string[] parameterNameAndValue = queryParameter.Split( + separator: new char[] + { + '=' + }, + count: 2 + ); + + if (parameterNameAndValue.Length != 2) + continue; // Remove parameter. + + string queryParameterName = parameterNameAndValue[0]; + string queryParameterValue = Uri.UnescapeDataString(parameterNameAndValue[1]); + + Match variableMatch = VariableRegex.Match(queryParameterValue); + if (variableMatch.Success) + { + string templateParameterName = variableMatch.Groups["VariableName"].Value; + if (String.IsNullOrWhiteSpace(templateParameterName)) + yield return new LiteralQuerySegment(queryParameterName, queryParameterValue); + + bool isOptional = variableMatch.Groups["VariableIsOptional"].Value.Length > 0; + + yield return new ParameterizedQuerySegment(queryParameterName, templateParameterName, isOptional); + } + else + yield return new LiteralQuerySegment(queryParameterName, queryParameterValue); + } + } + } +} diff --git a/src/KubeClient/Http/Templates/UriSegment.cs b/src/KubeClient/Http/Templates/UriSegment.cs new file mode 100644 index 0000000..bca6603 --- /dev/null +++ b/src/KubeClient/Http/Templates/UriSegment.cs @@ -0,0 +1,36 @@ +namespace KubeClient.Http.Templates +{ + /// + /// The base class for URI template segments that represent segments of the URI. + /// + abstract class UriSegment + : TemplateSegment + { + /// + /// Does the segment represent a directory (i.e. have a trailing slash?). + /// + readonly bool _isDirectory; + + /// + /// Create a new URI segment. + /// + /// + /// Does the segment represent a directory (i.e. have a trailing slash?). + /// + protected UriSegment(bool isDirectory) + { + _isDirectory = isDirectory; + } + + /// + /// Does the segment represent a directory (i.e. have a trailing slash?). + /// + public bool IsDirectory + { + get + { + return _isDirectory; + } + } + } +} diff --git a/src/KubeClient/Http/TypedFactoryExtensions.cs b/src/KubeClient/Http/TypedFactoryExtensions.cs new file mode 100644 index 0000000..54f293a --- /dev/null +++ b/src/KubeClient/Http/TypedFactoryExtensions.cs @@ -0,0 +1,38 @@ +using System; + +namespace KubeClient.Http +{ + /// + /// Extension methods for . + /// + public static class TypedFactoryExtensions + { + /// + /// Create a new HTTP request with the specified request URI. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request factory. + /// + /// + /// The request URI (can be relative or absolute). + /// + /// + /// The new . + /// + public static HttpRequest Create(this HttpRequestFactory requestFactory, string requestUri) + { + if (requestFactory == null) + throw new ArgumentNullException(nameof(requestFactory)); + + if (String.IsNullOrWhiteSpace(requestUri)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'requestUri'.", nameof(requestUri)); + + return requestFactory.Create( + new Uri(requestUri, UriKind.RelativeOrAbsolute) + ); + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.Headers.cs b/src/KubeClient/Http/TypedRequestExtensions.Headers.cs new file mode 100644 index 0000000..96e8d50 --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.Headers.cs @@ -0,0 +1,400 @@ +using System; +using System.Net.Http.Headers; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// / extension methods for HTTP headers. + /// + public static partial class TypedRequestExtensions + { + /// + /// Create a copy of the request that adds a header to each request. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The header value data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// The header value. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeader(this HttpRequest request, string headerName, TValue headerValue, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(headerName)); + + if (headerValue == null) + throw new ArgumentNullException(nameof(headerValue)); + + return request.WithHeaderFromProvider(headerName, + ValueProvider.FromConstantValue(headerValue).Convert().ValueToString(), + ensureQuoted + ); + } + + /// + /// Create a copy of the request that adds a header with its value obtained from the specified delegate. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The type of header value to add. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// A delegate that returns the header value for each request. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeader(this HttpRequest request, string headerName, Func getValue, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(headerName)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeaderFromProvider(headerName, + ValueProvider.FromFunction(getValue).Convert().ValueToString(), + ensureQuoted + ); + } + + /// + /// Create a copy of the request that adds a header with its value obtained from the specified delegate. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The type of header value to add. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// A delegate that extracts the header value from the context for each request. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeader(this HttpRequest request, string headerName, Func getValue, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(headerName)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeaderFromProvider(headerName, + ValueProvider.FromSelector(getValue).Convert().ValueToString(), + ensureQuoted + ); + } + + /// + /// Create a copy of the request, but with the specified media type added to the "Accept" header. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The media-type name. + /// + /// + /// An optional media-type quality. + /// + /// + /// The new . + /// + public static HttpRequest AcceptMediaType(this HttpRequest request, string mediaType, double? quality = null) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(mediaType)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'mediaType'.", nameof(mediaType)); + + MediaTypeWithQualityHeaderValue mediaTypeHeader = + quality.HasValue ? + new MediaTypeWithQualityHeaderValue(mediaType, quality.Value) + : + new MediaTypeWithQualityHeaderValue(mediaType); + + return request.WithRequestAction(requestMessage => + { + requestMessage.Headers.Accept.Add(mediaTypeHeader); + }); + } + + /// + /// Create a copy of the request, but with no media types in the "Accept" header. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The new . + /// + public static HttpRequest AcceptNoMediaTypes(this HttpRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + return request.WithRequestAction(requestMessage => + { + requestMessage.Headers.Accept.Clear(); + }); + } + + /// + /// Create a copy of the request that adds an "If-Match" header to each request. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The header value. + /// + /// + /// The new . + /// + public static HttpRequest WithIfMatchHeader(this HttpRequest request, string headerValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (headerValue == null) + throw new ArgumentNullException(nameof(headerValue)); + + return request.WithHeader("If-Match", () => headerValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-Match" header with its value obtained from the specified delegate. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that extracts the header value from the context for each request. + /// + /// + /// The new . + /// + public static HttpRequest WithIfMatchHeader(this HttpRequest request, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeader("If-Match", getValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-Match" header with its value obtained from the specified delegate. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that returns the header value for each request. + /// + /// + /// The new . + /// + public static HttpRequest WithIfMatchHeader(this HttpRequest request, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeader("If-Match", getValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-None-Match" header to each request. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The header value. + /// + /// + /// The new . + /// + public static HttpRequest WithIfNoneMatchHeader(this HttpRequest request, string headerValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (headerValue == null) + throw new ArgumentNullException(nameof(headerValue)); + + return request.WithHeader("If-None-Match", () => headerValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-None-Match" header with its value obtained from the specified delegate. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that extracts the header value from the context for each request. + /// + /// + /// The new . + /// + public static HttpRequest WithIfNoneMatchHeader(this HttpRequest request, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeader("If-None-Match", getValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds an "If-None-Match" header with its value obtained from the specified delegate. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that returns the header value for each request. + /// + /// + /// The new . + /// + public static HttpRequest WithIfNoneMatchHeader(this HttpRequest request, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithHeader("If-None-Match", getValue, ensureQuoted: true); + } + + /// + /// Create a copy of the request that adds a header to each request. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The header name. + /// + /// + /// The header value provider. + /// + /// + /// Ensure that the header value is quoted? + /// + /// + /// The new . + /// + public static HttpRequest WithHeaderFromProvider(this HttpRequest request, string headerName, IValueProvider valueProvider, bool ensureQuoted = false) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(headerName)); + + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return request.WithRequestAction((requestMessage, context) => + { + requestMessage.Headers.Remove(headerName); + + string headerValue = valueProvider.Get(context); + if (headerValue == null) + return; + + if (ensureQuoted) + headerValue = EnsureQuoted(headerValue); + + requestMessage.Headers.Add(headerName, headerValue); + }); + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.Helpers.cs b/src/KubeClient/Http/TypedRequestExtensions.Helpers.cs new file mode 100644 index 0000000..86bdab6 --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.Helpers.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// Helper methods for / extensions. + /// + public static partial class TypedRequestExtensions + { + /// + /// Configure the request URI (and template status) in the request properties. + /// + /// + /// The request properties to modify. + /// + /// + /// The request URI. + /// + static void SetUri(this IDictionary requestProperties, Uri requestUri) + { + if (requestProperties == null) + throw new ArgumentNullException(nameof(requestProperties)); + + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + requestProperties[nameof(IHttpRequest.Uri)] = requestUri; + requestProperties[nameof(IHttpRequest.IsUriTemplate)] = UriTemplate.IsTemplate(requestUri); + } + + /// + /// Ensure that the specified string is surrounted by quotes. + /// + /// + /// The string to examine. + /// + /// + /// The string, with quotes prepended / appended as required. + /// + /// + /// Some HTTP headers (such as If-Match) require their values to be quoted. + /// + static string EnsureQuoted(string str) + { + if (str == null) + throw new ArgumentNullException(nameof(str)); + + if (str.Length == 0) + return "\"\""; + + StringBuilder quotedStringBuilder = new StringBuilder(str); + + if (quotedStringBuilder[0] != '\"') + quotedStringBuilder.Insert(0, '\"'); + + if (quotedStringBuilder[quotedStringBuilder.Length - 1] != '\"') + quotedStringBuilder.Append('\"'); + + return quotedStringBuilder.ToString(); + } + + /// + /// Convert the specified object's properties to deferred parameters. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The type of object whose properties will form the parameters. + /// + /// + /// The object whose properties will form the parameters. + /// + /// + /// A sequence of key / value pairs representing the parameters. + /// + static IEnumerable>> CreateDeferredParameters(TParameters parameters) + { + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + // TODO: Refactor PropertyInfo retrieval logic (move it out to an extension method). + + // Yes yes yes, reflection might be "slow", but it's still blazingly fast compared to making a request over the network. + foreach (PropertyInfo property in typeof(TParameters).GetTypeInfo().DeclaredProperties) + { + // Ignore write-only properties. + if (!property.CanRead) + continue; + + // Public instance properties only. + if (!property.GetMethod.IsPublic || property.GetMethod.IsStatic) + continue; + + yield return new KeyValuePair>( + property.Name, + ValueProvider.FromSelector( + context => property.GetValue(parameters) + ) + .Convert().ValueToString() + ); + } + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.QueryParameters.cs b/src/KubeClient/Http/TypedRequestExtensions.QueryParameters.cs new file mode 100644 index 0000000..862a000 --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.QueryParameters.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// / extension methods for query parameters. + /// + public static partial class TypedRequestExtensions + { + /// + /// Create a copy of the request builder with the specified request URI query parameter. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The parameter data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// The parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameter(this HttpRequest request, string name, TValue value) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + return request.WithQueryParameterFromProvider(name, + ValueProvider.FromConstantValue(value?.ToString()) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI query parameter. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that returns the parameter value (cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameter(this HttpRequest request, string name, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithQueryParameterFromProvider( + name, + ValueProvider.FromFunction(getValue) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI query parameter. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that, given the current context, returns the parameter value (cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParameterFromProvider(this HttpRequest request, string name, IValueProvider valueProvider) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = request.QueryParameters.SetItem( + key: name, + value: valueProvider.Convert().ValueToString() + ); + }); + } + + /// + /// Create a copy of the request, but with query parameters from the specified object's properties. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The type of object whose properties will form the parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The object whose properties will form the parameters. + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParametersFrom(HttpRequest request, TParameters parameters) + { + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + return request.WithQueryParametersFromProviders( + CreateDeferredParameters(parameters) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI query parameters. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A sequence of 0 or more key / value pairs representing the query parameters (values cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithQueryParametersFromProviders(this HttpRequest request, IEnumerable>> queryParameters) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (queryParameters == null) + throw new ArgumentNullException(nameof(queryParameters)); + + bool modified = false; + ImmutableDictionary>.Builder queryParametersBuilder = request.QueryParameters.ToBuilder(); + foreach (KeyValuePair> queryParameter in queryParameters) + { + if (queryParameter.Value == null) + { + throw new ArgumentException( + String.Format( + "Query parameter '{0}' has a null getter; this is not supported.", + queryParameter.Key + ), + nameof(queryParameters) + ); + } + + queryParametersBuilder[queryParameter.Key] = queryParameter.Value; + modified = true; + } + + if (!modified) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = queryParametersBuilder.ToImmutable(); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI query parameter. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// The new . + /// + public static HttpRequest WithoutQueryParameter(this HttpRequest request, string name) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (!request.QueryParameters.ContainsKey(name)) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = request.QueryParameters.Remove(name); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI query parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter names. + /// + /// + /// The new . + /// + public static HttpRequest WithoutQueryParameters(this HttpRequest request, IEnumerable names) + { + if (names == null) + throw new ArgumentNullException(nameof(names)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.QueryParameters)] = request.QueryParameters.RemoveRange(names); + }); + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.RequestActions.cs b/src/KubeClient/Http/TypedRequestExtensions.RequestActions.cs new file mode 100644 index 0000000..e71b383 --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.RequestActions.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; + +namespace KubeClient.Http +{ + /// + /// / extension methods for request-configuration actions. + /// + public static partial class TypedRequestExtensions + { + /// + /// Create a copy of the request with the specified request-configuration action. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, RequestAction requestAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestAction == null) + throw new ArgumentNullException(nameof(requestAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.Add( + (message, context) => requestAction(message) + ); + }); + } + + /// + /// Create a copy of the request with the specified request-configuration action. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, RequestAction requestAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestAction == null) + throw new ArgumentNullException(nameof(requestAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.Add(requestAction); + }); + } + + /// + /// Create a copy of the request with the specified request-configuration actions. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, params RequestAction[] requestActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestActions == null) + throw new ArgumentNullException(nameof(requestActions)); + + if (requestActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.AddRange( + requestActions.Select(requestAction => + { + RequestAction requestActionWithContext = (message, context) => requestAction(message); + + return requestActionWithContext; + }) + ); + }); + } + + /// + /// Create a copy of the request with the specified request-configuration actions. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures outgoing request messages. + /// + /// + /// The new . + /// + public static HttpRequest WithRequestAction(this HttpRequest request, params RequestAction[] requestActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestActions == null) + throw new ArgumentNullException(nameof(requestActions)); + + if (requestActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.RequestActions)] = request.RequestActions.AddRange(requestActions); + }); + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.RequestUri.cs b/src/KubeClient/Http/TypedRequestExtensions.RequestUri.cs new file mode 100644 index 0000000..69999ee --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.RequestUri.cs @@ -0,0 +1,142 @@ +using System; + +namespace KubeClient.Http +{ + using Utilities; + + /// + /// / extension methods for request URIs. + /// + public static partial class TypedRequestExtensions + { + /// + /// Create a copy of the request with the specified base URI. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The request. + /// + /// + /// The request base URI (must be absolute). + /// + /// + /// The new . + /// + /// + /// The request already has an absolute URI. + /// + public static HttpRequest WithBaseUri(this HttpRequest request, Uri baseUri) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (baseUri == null) + throw new ArgumentNullException(nameof(baseUri)); + + if (!baseUri.IsAbsoluteUri) + throw new ArgumentException("The supplied base URI is not an absolute URI.", nameof(baseUri)); + + if (request.Uri.IsAbsoluteUri) + throw new InvalidOperationException("The request already has an absolute URI."); + + return request.Clone(properties => + { + properties.SetUri( + baseUri.AppendRelativeUri(request.Uri) + ); + }); + } + + /// + /// Create a copy of the request with the specified request URI. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The request. + /// + /// + /// The new request URI. + /// + /// Must be an absolute URI (otherwise, use ). + /// + /// + /// The new . + /// + public static HttpRequest WithUri(this HttpRequest request, Uri requestUri) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (requestUri == null) + throw new ArgumentNullException(nameof(requestUri)); + + return request.Clone(properties => + { + properties.SetUri(requestUri); + }); + } + + /// + /// Create a copy of the request with the specified request URI appended to its existing URI. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The request. + /// + /// + /// The relative request URI. + /// + /// + /// The new . + /// + public static HttpRequest WithRelativeUri(this HttpRequest request, string relativeUri) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(relativeUri)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'relativeUri'.", nameof(relativeUri)); + + return request.WithRelativeUri( + new Uri(relativeUri, UriKind.Relative) + ); + } + + /// + /// Create a copy of the request with the specified request URI appended to its existing URI. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The request. + /// + /// + /// The relative request URI. + /// + /// + /// The new . + /// + public static HttpRequest WithRelativeUri(this HttpRequest request, Uri relativeUri) + { + if (relativeUri == null) + throw new ArgumentNullException(nameof(relativeUri)); + + if (relativeUri.IsAbsoluteUri) + throw new ArgumentException("The specified URI is not a relative URI.", nameof(relativeUri)); + + return request.Clone(properties => + { + properties.SetUri( + request.Uri.AppendRelativeUri(relativeUri) + ); + }); + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.ResponseActions.cs b/src/KubeClient/Http/TypedRequestExtensions.ResponseActions.cs new file mode 100644 index 0000000..dad6514 --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.ResponseActions.cs @@ -0,0 +1,142 @@ +using System; +using System.Linq; + +namespace KubeClient.Http +{ + /// + /// / extension methods for response-processing actions. + /// + public static partial class TypedRequestExtensions + { + /// + /// Create a copy of the request with the specified response-processing action. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, ResponseAction responseAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseAction == null) + throw new ArgumentNullException(nameof(responseAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.Add( + (message, context) => responseAction(message) + ); + }); + } + + /// + /// Create a copy of the request with the specified response-processing action. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, ResponseAction responseAction) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseAction == null) + throw new ArgumentNullException(nameof(responseAction)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.Add(responseAction); + }); + } + + /// + /// Create a copy of the request with the specified response-processing actions. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, params ResponseAction[] responseActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseActions == null) + throw new ArgumentNullException(nameof(responseActions)); + + if (responseActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.AddRange( + responseActions.Select(responseAction => + { + ResponseAction responseActionWithContext = (message, context) => responseAction(message); + + return responseActionWithContext; + }) + ); + }); + } + + /// + /// Create a copy of the request with the specified response-processing actions. + /// + /// + /// The type of object used by the request when resolving deferred template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A delegate that configures incoming response messages. + /// + /// + /// The new . + /// + public static HttpRequest WithResponseAction(this HttpRequest request, params ResponseAction[] responseActions) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (responseActions == null) + throw new ArgumentNullException(nameof(responseActions)); + + if (responseActions.Length == 0) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.ResponseActions)] = request.ResponseActions.AddRange(responseActions); + }); + } + } +} diff --git a/src/KubeClient/Http/TypedRequestExtensions.TemplateParameters.cs b/src/KubeClient/Http/TypedRequestExtensions.TemplateParameters.cs new file mode 100644 index 0000000..d8f211e --- /dev/null +++ b/src/KubeClient/Http/TypedRequestExtensions.TemplateParameters.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace KubeClient.Http +{ + using ValueProviders; + + /// + /// / extension methods for template parameters. + /// + public static partial class TypedRequestExtensions + { + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The parameter data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// The parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameter(this HttpRequest request, string name, TValue value) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + return request.WithTemplateParameterFromProvider(name, + ValueProvider.FromConstantValue(value?.ToString()) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The parameter data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that returns the parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameter(this HttpRequest request, string name, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithTemplateParameterFromProvider(name, + ValueProvider.FromFunction(getValue).Convert().ValueToString() + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The parameter data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// Delegate that returns the parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameter(this HttpRequest request, string name, Func getValue) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return request.WithTemplateParameterFromProvider(name, + ValueProvider.FromSelector(getValue).Convert().ValueToString() + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameter. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The parameter data-type. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// A value provider that, given the current context, returns the parameter value. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameterFromProvider(this HttpRequest request, string name, IValueProvider valueProvider) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = request.TemplateParameters.SetItem( + key: name, + value: valueProvider.Convert().ValueToString() + ); + }); + } + + /// + /// Create a copy of the request, but with template parameters from the specified object's properties. + /// + /// + /// The type of object used as a context for resolving deferred parameters. + /// + /// + /// The type of object whose properties will form the parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The object whose properties will form the parameters. + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParameters(HttpRequest request, TParameters parameters) + { + if (ReferenceEquals(parameters, null)) + throw new ArgumentNullException(nameof(parameters)); + + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + return request.WithTemplateParametersFromProviders( + CreateDeferredParameters(parameters) + ); + } + + /// + /// Create a copy of the request builder with the specified request URI template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// A sequence of 0 or more key / value pairs representing the template parameters (values cannot be null). + /// + /// + /// The new . + /// + public static HttpRequest WithTemplateParametersFromProviders(this HttpRequest request, IEnumerable>> templateParameters) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (templateParameters == null) + throw new ArgumentNullException(nameof(templateParameters)); + + bool modified = false; + ImmutableDictionary>.Builder templateParametersBuilder = request.TemplateParameters.ToBuilder(); + foreach (KeyValuePair> templateParameter in templateParameters) + { + if (templateParameter.Value == null) + { + throw new ArgumentException( + String.Format( + "Template parameter '{0}' has a null getter; this is not supported.", + templateParameter.Key + ), + nameof(templateParameters) + ); + } + + templateParametersBuilder[templateParameter.Key] = templateParameter.Value; + modified = true; + } + + if (!modified) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = templateParametersBuilder.ToImmutable(); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI template parameter. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter name. + /// + /// + /// The new . + /// + public static HttpRequest WithoutTemplateParameter(this HttpRequest request, string name) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (String.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'name'.", nameof(name)); + + if (!request.TemplateParameters.ContainsKey(name)) + return request; + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = request.TemplateParameters.Remove(name); + }); + } + + /// + /// Create a copy of the request builder without the specified request URI template parameters. + /// + /// + /// The HTTP request. + /// + /// + /// The parameter names. + /// + /// + /// The new . + /// + public static HttpRequest WithoutTemplateParameters(this HttpRequest request, IEnumerable names) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + + if (names == null) + throw new ArgumentNullException(nameof(names)); + + return request.Clone(properties => + { + properties[nameof(HttpRequest.TemplateParameters)] = request.TemplateParameters.RemoveRange(names); + }); + } + } +} diff --git a/src/KubeClient/Http/UriTemplate.cs b/src/KubeClient/Http/UriTemplate.cs new file mode 100644 index 0000000..b9b69b2 --- /dev/null +++ b/src/KubeClient/Http/UriTemplate.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace KubeClient.Http +{ + using Templates; + + /// + /// Populates parameterised URI templates. + /// + public sealed class UriTemplate + { + /// + /// The URI template. + /// + readonly string _template; + + /// + /// The template's URI segments. + /// + readonly IReadOnlyList _uriSegments; + + /// + /// The template's URI segments. + /// + readonly IReadOnlyList _querySegments; + + /// + /// Create a new URI template. + /// + /// + /// The template. + /// + public UriTemplate(string template) + { + if (String.IsNullOrWhiteSpace(template)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'template'.", nameof(template)); + + _template = template; + + IReadOnlyList templateSegments = TemplateSegment.Parse(_template); + _uriSegments = templateSegments.OfType().ToArray(); + if (_uriSegments.Count == 0) + throw new UriTemplateException("Invalid URI template (contains no path segments)."); + + _querySegments = templateSegments.OfType().ToArray(); + } + + /// + /// Build a URI from the template. + /// + /// + /// A dictionary containing the template parameters. + /// + /// + /// The generated URI. + /// + public Uri Populate(IDictionary templateParameters) + { + return Populate(null, templateParameters); + } + + /// + /// Build a URI from the template. + /// + /// + /// The base URI, or null to generate a relative URI. + /// + /// + /// A dictionary containing the template parameters. + /// + /// + /// The generated URI. + /// + public Uri Populate(Uri baseUri, IDictionary templateParameters) + { + if (baseUri != null && !baseUri.IsAbsoluteUri) + throw new UriTemplateException($"Base URI '{baseUri}' is not an absolute URI."); + + if (templateParameters == null) + throw new ArgumentNullException(nameof(templateParameters)); + + TemplateEvaluationContext evaluationContext = new TemplateEvaluationContext(templateParameters); + StringBuilder uriBuilder = new StringBuilder(); + if (baseUri != null) + { + uriBuilder.Append( + baseUri.GetComponents(UriComponents.Scheme | UriComponents.StrongAuthority, UriFormat.UriEscaped) + ); + } + + if (_uriSegments.Count > 0) + { + foreach (UriSegment uriSegment in _uriSegments) + { + string segmentValue = uriSegment.GetValue(evaluationContext); + if (segmentValue == null) + continue; + + // TODO: ensure we have tests for the existing baseline before we even * consider * changing the escape mechanism +#pragma warning disable SYSLIB0013 // Type or member is obsolete + uriBuilder.Append( + Uri.EscapeUriString(segmentValue) + ); +#pragma warning restore SYSLIB0013 // Type or member is obsolete + + if (uriSegment.IsDirectory) + uriBuilder.Append('/'); + } + } + else + uriBuilder.Append('/'); + + bool isFirstParameterWithValue = true; + foreach (QuerySegment segment in _querySegments) + { + string queryParameterValue = segment.GetValue(evaluationContext); + if (queryParameterValue == null) + continue; + + // Different prefix for first parameter that has a value. + if (isFirstParameterWithValue) + { + uriBuilder.Append('?'); + + isFirstParameterWithValue = false; + } + else + uriBuilder.Append('&'); + + uriBuilder.AppendFormat( + "{0}={1}", + Uri.EscapeDataString(segment.QueryParameterName), + Uri.EscapeDataString(queryParameterValue) + ); + } + + return new Uri(uriBuilder.ToString(), UriKind.RelativeOrAbsolute); + } + + /// + /// Does the specified URI represent a template? + /// + /// + /// The URI. + /// + /// + /// true, if any of the URI's components are parameterised (i.e. have non-constant values); otherwise, false. + /// + public static bool IsTemplate(Uri uri) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + + return IsTemplate(uri.ToString()); + } + + /// + /// Does the specified URI represent a template? + /// + /// + /// The URI. + /// + /// + /// true, if any of the URI's components are parameterised (i.e. have non-constant values); otherwise, false. + /// + public static bool IsTemplate(string uri) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + + IReadOnlyList templateSegments = TemplateSegment.Parse(uri); + + return templateSegments.Any(segment => segment.IsParameterized); + } + } +} diff --git a/src/KubeClient/Http/UriTemplateException.cs b/src/KubeClient/Http/UriTemplateException.cs new file mode 100644 index 0000000..f6dce8b --- /dev/null +++ b/src/KubeClient/Http/UriTemplateException.cs @@ -0,0 +1,36 @@ +using System; + +namespace KubeClient.Http +{ + /// + /// Exception raised when a is invalid or is missing required information. + /// + public class UriTemplateException + : KubeClientException + { + /// + /// Create a new . + /// + /// + /// The exception message. + /// + public UriTemplateException(string message) + : base(message) + { + } + + /// + /// Create a new . + /// + /// + /// The exception that caused this exception to be raised. + /// + /// + /// The exception message. + /// + public UriTemplateException(Exception innerException, string message) + : base(message, innerException) + { + } + } +} diff --git a/src/KubeClient/Http/Utilities/HttpRequestBase.cs b/src/KubeClient/Http/Utilities/HttpRequestBase.cs new file mode 100644 index 0000000..1a41de4 --- /dev/null +++ b/src/KubeClient/Http/Utilities/HttpRequestBase.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; + +namespace KubeClient.Http +{ + using Utilities; + + using RequestProperties = ImmutableDictionary; + + /// + /// The base class for HTTP request templates. + /// + public abstract class HttpRequestBase + : IHttpRequestProperties + { + #region Instance data + + /// + /// The request properties. + /// + readonly RequestProperties _properties; + + #endregion // Instance data + + #region Construction + + /// + /// Create a new HTTP request. + /// + /// + /// The request properties. + /// + protected HttpRequestBase(ImmutableDictionary properties) + { + if (properties == null) + throw new ArgumentNullException(nameof(properties)); + + _properties = properties; + + EnsurePropertyType(nameof(Uri)); + EnsurePropertyType(nameof(IsUriTemplate)); + } + + #endregion // Construction + + #region IHttpRequest + + /// + /// The request URI. + /// + public Uri Uri => GetOptionalProperty(); + + /// + /// Is the request URI a template? + /// + public bool IsUriTemplate => GetOptionalProperty(); + + /// + /// All properties for the request. + /// + public ImmutableDictionary Properties => _properties; + + #endregion // IHttpRequest + + #region Request properties + + /// + /// Determine whether the specified property is defined for the request. + /// + /// + /// The property name. + /// + /// + /// true, if the request is defined; otherwise, false. + /// + protected bool HaveProperty([CallerMemberName] string propertyName = null) + { + if (String.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'propertyName'.", nameof(propertyName)); + + return _properties.ContainsKey(propertyName); + } + + /// + /// Get the specified request property. + /// + /// + /// The type of property to retrieve. + /// + /// + /// The name of the property to retrieve. + /// + /// + /// The property value. + /// + /// + /// is null, empty, or entirely composed of whitespace. + /// + /// + /// The specified property is not defined. + /// + protected TProperty GetProperty([CallerMemberName] string propertyName = null) + { + if (String.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'propertyName'.", nameof(propertyName)); + + object propertyValue; + if (!_properties.TryGetValue(propertyName, out propertyValue)) + throw new KeyNotFoundException($"Property '{propertyName}' is not defined."); + + return (TProperty)propertyValue; + } + + /// + /// Get the specified request property. + /// + /// + /// The type of property to retrieve. + /// + /// + /// The name of the property to retrieve. + /// + /// + /// The default value to return if the property is not defined. + /// + /// + /// The property value, or the default value if the property is not defined. + /// + /// + /// is null, empty, or entirely composed of whitespace. + /// + /// + /// The specified property is not defined. + /// + protected TProperty GetOptionalProperty([CallerMemberName] string propertyName = null, TProperty defaultValue = default(TProperty)) + { + if (String.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'propertyName'.", nameof(propertyName)); + + object propertyValue; + if (_properties.TryGetValue(propertyName, out propertyValue)) + return (TProperty)propertyValue; + + return defaultValue; + } + + /// + /// Ensure that the specified property (if defined) is of the correct type. + /// + /// + /// The expected property type. + /// + /// + /// The name of the property to validate. + /// + /// + /// is null, empty, or entirely composed of whitespace. + /// + protected void EnsurePropertyType(string propertyName) + { + if (String.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Argument cannot be null, empty, or composed entirely of whitespace: 'propertyName'.", nameof(propertyName)); + + object propertyValue; + if (!_properties.TryGetValue(propertyName, out propertyValue)) + return; + + if (propertyValue is TProperty) + return; + + // It's not of the correct type, but is that because it's null? + Type propertyType = typeof(TProperty); + if (propertyValue != null) + { + throw new InvalidOperationException( + $"Value for property '{propertyName}' has unexpected type '{propertyType.FullName}' (should be '{propertyValue.GetType().FullName}')." + ); + } + + // It's null; is that legal? + if (typeof(TProperty).IsNullable()) + return; + + throw new InvalidOperationException( + $"Property '{propertyName}' is null but its type ('{propertyType.FullName}') is not nullable." + ); + } + + /// + /// Clone the request properties, but with the specified changes. + /// + /// + /// A delegate that modifies the request properties. + /// + /// + /// The cloned request properties. + /// + protected ImmutableDictionary CloneProperties(Action modifications) + { + if (modifications == null) + throw new ArgumentNullException(nameof(modifications)); + + RequestProperties.Builder requestProperties = _properties.ToBuilder(); + modifications(requestProperties); + + return requestProperties.ToImmutable(); + } + + #endregion // Request properties + + #region Cloning + + /// + /// Clone the request. + /// + /// + /// A delegate that performs modifications to the request properties. + /// + /// + /// The cloned request. + /// + public virtual HttpRequestBase Clone(Action> modifications) + { + if (modifications == null) + throw new ArgumentNullException(nameof(modifications)); + + return CreateInstance( + CloneProperties(modifications) + ); + } + + /// + /// Create a new instance of the HTTP request using the specified properties. + /// + /// + /// The request properties. + /// + /// + /// The new HTTP request instance. + /// + protected abstract HttpRequestBase CreateInstance(ImmutableDictionary requestProperties); + + #endregion // Cloning + + #region ToString + + /// + /// Convert the HTTP request to a textual representation. + /// + /// + /// The textual representation. + /// + public override string ToString() + { + return $"HTTP Request ({Uri?.ToString() ?? "empty"})"; + } + + #endregion // ToString + } +} diff --git a/src/KubeClient/Http/Utilities/ReflectionHelper.cs b/src/KubeClient/Http/Utilities/ReflectionHelper.cs new file mode 100644 index 0000000..5b624b7 --- /dev/null +++ b/src/KubeClient/Http/Utilities/ReflectionHelper.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Concurrent; +using System.Reflection; + +namespace KubeClient.Http.Utilities +{ + /// + /// Helper methods for working with Reflection. + /// + public static class ReflectionHelper + { + /// + /// Types that are known to be nullable. + /// + static readonly ConcurrentDictionary _nullableTypes = new ConcurrentDictionary(); + + /// + /// Determine whether a reference to an instance of the type can be null. + /// + /// + /// The type. + /// + /// + /// true, if the represents a reference type or a nullable value type. + /// + public static bool IsNullable(this Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + + return _nullableTypes.GetOrAdd(type, targetType => + { + if (type.GetTypeInfo().IsClass) + return true; + + // For non-nullable types, Nullable.GetUnderlyingType just returns the type supplied to it. + return Nullable.GetUnderlyingType(type) != type; + }); + } + } +} diff --git a/src/KubeClient/Http/Utilities/UriHelper.cs b/src/KubeClient/Http/Utilities/UriHelper.cs new file mode 100644 index 0000000..e9bac22 --- /dev/null +++ b/src/KubeClient/Http/Utilities/UriHelper.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Text; + +namespace KubeClient.Http.Utilities +{ + /// + /// Helper methods for working with s. + /// + public static class UriHelper + { + /// + /// Parse the URI's query parameters. + /// + /// + /// The URI. + /// + /// + /// A containing key / value pairs representing the query parameters. + /// + public static NameValueCollection ParseQueryParameters(this Uri uri) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + + NameValueCollection queryParameters = new NameValueCollection(); + if (String.IsNullOrWhiteSpace(uri.Query)) + return queryParameters; + + Debug.Assert(uri.Query[0] == '?', "Query string does not start with '?'."); + + string[] keyValuePairs = uri.Query.Substring(1).Split( + separator: new char[] { '&' }, + options: StringSplitOptions.RemoveEmptyEntries + ); + + foreach (string keyValuePair in keyValuePairs) + { + string[] keyAndValue = keyValuePair.Split( + separator: new char[] { '=' }, + count: 2 + ); + + string key = keyAndValue[0]; + string value = keyAndValue.Length == 2 ? keyAndValue[1] : null; + + queryParameters[key] = value; + } + + return queryParameters; + } + + /// + /// Create a copy of URI with its query component populated with the supplied parameters. + /// + /// + /// The used to construct the URI. + /// + /// + /// A representing the query parameters. + /// + /// + /// A new URI with the specified query. + /// + public static Uri WithQueryParameters(this Uri uri, NameValueCollection parameters) + { + if (uri == null) + throw new ArgumentNullException(nameof(uri)); + + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + return + new UriBuilder(uri) + .WithQueryParameters(parameters) + .Uri; + } + + /// + /// Populate the query component of the URI. + /// + /// + /// The used to construct the URI + /// + /// + /// A representing the query parameters. + /// + /// + /// The URI builder (enables inline use). + /// + public static UriBuilder WithQueryParameters(this UriBuilder uriBuilder, NameValueCollection parameters) + { + if (uriBuilder == null) + throw new ArgumentNullException(nameof(uriBuilder)); + + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + if (parameters.Count == 0) + return uriBuilder; + + // Yes, you could do this using String.Join, but it seems a bit wasteful to allocate all those "key=value" strings only to throw them away again. + + Action addQueryParameter = (builder, parameterIndex) => + { + string parameterName = parameters.GetKey(parameterIndex); + string parameterValue = parameters.Get(parameterIndex); + + builder.Append(parameterName); + + // Support for /foo/bar?x=1&y&z=2 + if (parameterValue != null) + { + builder.Append('='); + + // TODO: ensure we have tests for the existing baseline before we even * consider * changing the escape mechanism +#pragma warning disable SYSLIB0013 // Type or member is obsolete + builder.Append( + Uri.EscapeUriString(parameterValue) + ); +#pragma warning restore SYSLIB0013 // Type or member is obsolete + } + }; + + StringBuilder queryBuilder = new StringBuilder(); + + // First parameter has no prefix. + addQueryParameter(queryBuilder, 0); + + // Subsequent parameters are separated with an '&' + for (int parameterIndex = 1; parameterIndex < parameters.Count; parameterIndex++) + { + queryBuilder.Append('&'); + addQueryParameter(queryBuilder, parameterIndex); + } + + uriBuilder.Query = queryBuilder.ToString(); + + return uriBuilder; + } + + /// + /// Append a relative URI to the base URI. + /// + /// + /// The base URI. + /// + /// A trailing "/" will be appended, if necessary. + /// + /// + /// The relative URI to append (leading slash will be trimmed, if required). + /// + /// + /// The concatenated URI. + /// + /// + /// This function is required because, sometimes, appending of a relative path to a URI can behave counter-intuitively. + /// If the base URI does not have a trailing "/", then its last path segment is *replaced* by the relative UI. This is hardly ever what you actually want. + /// + internal static Uri AppendRelativeUri(this Uri baseUri, Uri relativeUri) + { + if (baseUri == null) + throw new ArgumentNullException(nameof(baseUri)); + + if (relativeUri == null) + throw new ArgumentNullException(nameof(relativeUri)); + + if (relativeUri.IsAbsoluteUri) + return relativeUri; + + if (baseUri.IsAbsoluteUri) + { + // Working with relative URIs is painful (e.g. you can't use .PathAndQuery). + string relativeUriString = relativeUri.ToString(); + + // Handle the case where the relative URI only contains query parameters (no path). + if (relativeUriString[0] == '?') + { + StringBuilder absoluteUriBuilder = new StringBuilder(baseUri.AbsoluteUri); + if (String.IsNullOrWhiteSpace(baseUri.Query)) + absoluteUriBuilder.Append('?'); + else + absoluteUriBuilder.Append('&'); + + absoluteUriBuilder.Append(relativeUriString, + startIndex: 1, + count: relativeUriString.Length - 1 + ); + + return new Uri( + absoluteUriBuilder.ToString() + ); + } + + // Retain URI-concatenation semantics, except that we behave the same whether trailing slash is present or absent. + UriBuilder uriBuilder = new UriBuilder(baseUri); + + string[] relativePathAndQuery = + relativeUriString.Split( + new[] { '?' }, + count: 2, + options: StringSplitOptions.RemoveEmptyEntries + ); + + uriBuilder.Path = AppendPaths(uriBuilder.Path, relativePathAndQuery[0]); + + // Merge query parameters, if required. + if (relativePathAndQuery.Length == 2) + { + uriBuilder.Query = MergeQueryStrings( + baseQueryString: uriBuilder.Query, + additionalQueryString: relativePathAndQuery[1] + ); + } + + return uriBuilder.Uri; + } + + // Irritatingly, you can't use UriBuilder with a relative path. + return new Uri( + AppendPaths(baseUri.ToString(), relativeUri.ToString()), + UriKind.Relative + ); + } + + /// + /// Contatenate 2 relative URI paths. + /// + /// + /// The base URI path. + /// + /// + /// The relative URI path to append to the base URI path. + /// + /// + /// The appended paths, separated by a single slash. + /// + static string AppendPaths(string basePath, string relativePath) + { + if (basePath == null) + throw new ArgumentNullException(nameof(basePath)); + + if (relativePath == null) + throw new ArgumentNullException(nameof(relativePath)); + + StringBuilder pathBuilder = new StringBuilder(basePath); + if (pathBuilder.Length == 0 || pathBuilder[pathBuilder.Length - 1] != '/') + pathBuilder.Append("/"); + + int relativePathStartIndex = + (relativePath.Length > 0 && relativePath[0] == '/') ? 1 : 0; + + pathBuilder.Append( + relativePath, + startIndex: (relativePath.Length > 0 && relativePath[0] == '/') ? 1 : 0, + count: relativePath.Length - relativePathStartIndex + ); + + return pathBuilder.ToString(); + } + + /// + /// Merge 2 query strings. + /// + /// + /// The base query string. + /// + /// If empty, the additional query string is used. + /// + /// + /// The additional query string. + /// + /// If empty, the base query string is used. + /// + /// + /// + /// Does not remove duplicate parameters. + /// + static string MergeQueryStrings(string baseQueryString, string additionalQueryString) + { + if (String.IsNullOrWhiteSpace(additionalQueryString)) + return baseQueryString; + + if (String.IsNullOrWhiteSpace(baseQueryString)) + return additionalQueryString; + + StringBuilder combinedQueryParameters = new StringBuilder(); + if (baseQueryString[0] != '?') + combinedQueryParameters.Append('?'); + + combinedQueryParameters.Append(baseQueryString); + + if (additionalQueryString[0] != '?') + combinedQueryParameters.Append(additionalQueryString); + else + combinedQueryParameters.Append(additionalQueryString, 1, additionalQueryString.Length - 1); + + return combinedQueryParameters.ToString(); + } + } +} diff --git a/src/KubeClient/Http/ValueProviders/IValueProvider.cs b/src/KubeClient/Http/ValueProviders/IValueProvider.cs new file mode 100644 index 0000000..90d7b21 --- /dev/null +++ b/src/KubeClient/Http/ValueProviders/IValueProvider.cs @@ -0,0 +1,25 @@ +namespace KubeClient.Http.ValueProviders +{ + /// + /// Represents the provider for a value from an instance of . + /// + /// + /// The source type from which the value is extracted. + /// + /// + /// The type of value returned by the provider. + /// + public interface IValueProvider + { + /// + /// Extract the value from the specified context. + /// + /// + /// The instance from which the value is to be extracted. + /// + /// + /// The value. + /// + TValue Get(TContext source); + } +} diff --git a/src/KubeClient/Http/ValueProviders/ValueProvider.cs b/src/KubeClient/Http/ValueProviders/ValueProvider.cs new file mode 100644 index 0000000..ceb7a49 --- /dev/null +++ b/src/KubeClient/Http/ValueProviders/ValueProvider.cs @@ -0,0 +1,199 @@ +using System; + +namespace KubeClient.Http.ValueProviders +{ + /// + /// Factory methods for creating value providers. + /// + /// + /// The type used as a context for each request. + /// + public static class ValueProvider + { + /// + /// Create a value provider from the specified selector function. + /// + /// + /// The type of value returned by the selector. + /// + /// + /// A selector function that, when given an instance of , and returns a well-known value of type derived from the context. + /// + /// + /// The value provider. + /// + public static IValueProvider FromSelector(Func selector) + { + if (selector == null) + throw new ArgumentNullException(nameof(selector)); + + return new SelectorValueProvider(selector); + } + + /// + /// Create a value provider from the specified function. + /// + /// + /// The type of value returned by the function. + /// + /// + /// A function that returns a well-known value of type . + /// + /// + /// The value provider. + /// + public static IValueProvider FromFunction(Func getValue) + { + if (getValue == null) + throw new ArgumentNullException(nameof(getValue)); + + return new FunctionValueProvider(getValue); + } + + /// + /// Create a value provider from the specified constant value. + /// + /// + /// The type of value returned by the provider. + /// + /// + /// A constant value that is returned by the provider. + /// + /// + /// The value provider. + /// + public static IValueProvider FromConstantValue(TValue value) + { + return new ConstantValueProvider(value); + } + + /// + /// Value provider that invokes a selector function on the context to extract its value. + /// + /// + /// The type of value returned by the provider. + /// + class SelectorValueProvider + : IValueProvider + { + /// + /// The selector function that extracts a value from the context. + /// + readonly Func _selector; + + /// + /// Create a new selector-based value provider. + /// + /// + /// The selector function that extracts a value from the context. + /// + public SelectorValueProvider(Func selector) + { + _selector = selector; + } + + /// + /// Extract the value from the specified context. + /// + /// + /// The TContext instance from which the value is to be extracted. + /// + /// + /// The value. + /// + public TValue Get(TContext source) + { + if (source == null) + throw new InvalidOperationException("The current request template has one more more deferred parameters that refer to its context; the context parameter must therefore be supplied."); + + return _selector(source); + } + } + + /// + /// Value provider that invokes a function to extract its value. + /// + /// + /// The type of value returned by the provider. + /// + class FunctionValueProvider + : IValueProvider + { + /// + /// The function that is invoked to provide a value. + /// + readonly Func _getValue; + + /// + /// Create a new function-based value provider. + /// + /// + /// The function that is invoked to provide a value. + /// + public FunctionValueProvider(Func getValue) + { + _getValue = getValue; + } + + /// + /// Extract the value from the specified context. + /// + /// + /// The TContext instance from which the value is to be extracted. + /// + /// + /// The value. + /// + public TValue Get(TContext source) + { + if (source == null) + return default(TValue); // AF: Is this correct? + + return _getValue(); + } + } + + /// + /// Value provider that returns a constant value. + /// + /// + /// The type of value returned by the provider. + /// + class ConstantValueProvider + : IValueProvider + { + /// + /// The constant value returned by the provider. + /// + readonly TValue _value; + + /// + /// Create a new constant value provider. + /// + /// + /// The constant value returned by the provider. + /// + public ConstantValueProvider(TValue value) + { + _value = value; + } + + /// + /// Extract the value from the specified context. + /// + /// + /// The TContext instance from which the value is to be extracted. + /// + /// + /// The value. + /// + public TValue Get(TContext source) + { + if (source == null) + return default(TValue); // AF: Is this correct? + + return _value; + } + } + } +} diff --git a/src/KubeClient/Http/ValueProviders/ValueProviderConversion.cs b/src/KubeClient/Http/ValueProviders/ValueProviderConversion.cs new file mode 100644 index 0000000..7e58e04 --- /dev/null +++ b/src/KubeClient/Http/ValueProviders/ValueProviderConversion.cs @@ -0,0 +1,84 @@ +using System; + +namespace KubeClient.Http.ValueProviders +{ + /// + /// Conversion operations for a value provider. + /// + /// + /// The type used as a context for each request. + /// + /// + /// The type of value returned by the value provider. + /// + public struct ValueProviderConversion + { + /// + /// Create a new value-provider conversion. + /// + /// + /// The value provider being converted. + /// + public ValueProviderConversion(IValueProvider valueProvider) + : this() + { + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + ValueProvider = valueProvider; + } + + /// + /// The value provider being converted. + /// + public IValueProvider ValueProvider { get; } + + /// + /// Wrap the specified value provider in a value provider that utilises a more-derived context type. + /// + /// + /// The more-derived type used by the new provider as a context for each request. + /// + /// + /// The outer (converting) value provider. + /// + public IValueProvider ContextTo() + where TDerivedContext : TContext + { + // Can't close over members of structs. + IValueProvider valueProvider = ValueProvider; + + return ValueProvider.FromSelector( + context => valueProvider.Get(context) + ); + } + + /// + /// Wrap the value provider in a value provider that converts its value to a string. + /// + /// + /// The outer (converting) value provider. + /// + /// + /// If the underlying value is null then the converted string value will be null, too. + /// + public IValueProvider ValueToString() + { + // Special-case conversion to save on allocations. + if (typeof(TValue) == typeof(string)) + return (IValueProvider)ValueProvider; + + // Can't close over members of structs. + IValueProvider valueProvider = ValueProvider; + + return ValueProvider.FromSelector( + context => + { + TValue value = valueProvider.Get(context); + + return value != null ? value.ToString() : null; + } + ); + } + } +} \ No newline at end of file diff --git a/src/KubeClient/Http/ValueProviders/ValueProviderExtensions.cs b/src/KubeClient/Http/ValueProviders/ValueProviderExtensions.cs new file mode 100644 index 0000000..d432b22 --- /dev/null +++ b/src/KubeClient/Http/ValueProviders/ValueProviderExtensions.cs @@ -0,0 +1,33 @@ +using System; + +namespace KubeClient.Http.ValueProviders +{ + /// + /// Extension methods for . + /// + public static class ValueProviderExtensions + { + /// + /// Perform a conversion on the value provider. + /// + /// + /// The source type from which the value is extracted. + /// + /// + /// The type of value extracted by the provider. + /// + /// + /// The value provider. + /// + /// + /// A whose methods can be used to select the conversion to perform on the value converter. + /// + public static ValueProviderConversion Convert(this IValueProvider valueProvider) + { + if (valueProvider == null) + throw new ArgumentNullException(nameof(valueProvider)); + + return new ValueProviderConversion(valueProvider); + } + } +} diff --git a/src/KubeClient/KubeApiClient.cs b/src/KubeClient/KubeApiClient.cs index 700ae51..0399399 100644 --- a/src/KubeClient/KubeApiClient.cs +++ b/src/KubeClient/KubeApiClient.cs @@ -1,5 +1,4 @@ using HTTPlease; -using HTTPlease.Diagnostics; using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; @@ -8,7 +7,6 @@ namespace KubeClient { - using MessageHandlers; using ResourceClients; /// diff --git a/src/KubeClient/KubeApiException.cs b/src/KubeClient/KubeApiException.cs index 8d5ff15..56006fc 100644 --- a/src/KubeClient/KubeApiException.cs +++ b/src/KubeClient/KubeApiException.cs @@ -1,12 +1,6 @@ using HTTPlease; using System; -#if NETSTANDARD2_1 - -using System.Runtime.Serialization; - -#endif // NETSTANDARD2_1 - namespace KubeClient { using Models; @@ -14,9 +8,6 @@ namespace KubeClient /// /// Exception raised when an error result is returned by the Kubernetes API. /// -#if NETSTANDARD2_1 - [Serializable] -#endif // NETSTANDARD2_1 public class KubeApiException : KubeClientException { @@ -101,24 +92,6 @@ public KubeApiException(HttpRequestException requestException) Status = requestException.Response; } -#if NETSTANDARD2_1 - - /// - /// Deserialisation constructor. - /// - /// - /// The serialisation data store. - /// - /// - /// A containing information about the origin of the serialised data. - /// - protected KubeApiException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } - -#endif // NETSTANDARD2_1 - /// /// A Kubernetes model that (if present) contains more information about the error. /// diff --git a/src/KubeClient/KubeClientException.cs b/src/KubeClient/KubeClientException.cs index 513913b..6f4dc0c 100644 --- a/src/KubeClient/KubeClientException.cs +++ b/src/KubeClient/KubeClientException.cs @@ -1,22 +1,10 @@ -using HTTPlease; using System; -#if NETSTANDARD2_1 - -using System.Runtime.Serialization; - -#endif // NETSTANDARD2_1 - namespace KubeClient { - using Models; - /// /// Exception raised when an error is encountered by the Kubernetes API client. /// -#if NETSTANDARD20 - [Serializable] -#endif // NETSTANDARD20 public class KubeClientException : Exception { @@ -49,23 +37,5 @@ public KubeClientException(string message, Exception innerException) : base(message, innerException) { } - -#if NETSTANDARD2_1 - - /// - /// Deserialisation constructor. - /// - /// - /// The serialisation data store. - /// - /// - /// A containing information about the origin of the serialised data. - /// - protected KubeClientException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } - -#endif // NETSTANDARD2_1 } }