diff --git a/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs b/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs
new file mode 100644
index 000000000..402ae1924
--- /dev/null
+++ b/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Net.Http;
+using k8s.Models;
+
+namespace k8s.Fluent
+{
+ public static class KubernetesFluent
+ {
+ /// Creates a new Kubernetes object of the given type and sets its and
+ /// .
+ ///
+ public static T New(this Kubernetes client) where T : IKubernetesObject, new() => client.Scheme.New();
+
+ /// Creates a new Kubernetes object of the given type and sets its ,
+ /// , and .
+ ///
+ public static T New(this Kubernetes client, string name) where T : IKubernetesObject, new() => client.Scheme.New(name);
+
+ /// Creates a new Kubernetes object of the given type and sets its ,
+ /// , , and .
+ ///
+ public static T New(this Kubernetes client, string ns, string name) where T : IKubernetesObject, new() => client.Scheme.New(ns, name);
+
+ /// Creates a new using the given
+ /// ( by default).
+ ///
+ public static KubernetesRequest Request(this Kubernetes client, HttpMethod method = null) => new KubernetesRequest(client).Method(method);
+
+ /// Creates a new using the given
+ /// and resource URI components.
+ ///
+ public static KubernetesRequest Request(this Kubernetes client,
+ HttpMethod method, string type = null, string ns = null, string name = null, string group = null, string version = null) =>
+ new KubernetesRequest(client).Method(method).Group(group).Version(version).Type(type).Namespace(ns).Name(name);
+
+ /// Creates a new to access the given type of object.
+ public static KubernetesRequest Request(this Kubernetes client, Type type) => new KubernetesRequest(client).GVK(type);
+
+ /// Creates a new to access the given type of object with an optional name and namespace.
+ public static KubernetesRequest Request(this Kubernetes client, HttpMethod method, Type type, string ns = null, string name = null) =>
+ Request(client, method).GVK(type).Namespace(ns).Name(name);
+
+ /// Creates a new to access the given type of object with an optional name and namespace.
+ public static KubernetesRequest Request(this Kubernetes client, string ns = null, string name = null) => Request(client, null, typeof(T), ns, name);
+
+ /// Creates a new to access the given object.
+ public static KubernetesRequest Request(this Kubernetes client, IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(client).Set(obj, setBody);
+ }
+}
diff --git a/src/KubernetesClient/Fluent/KubernetesRequest.cs b/src/KubernetesClient/Fluent/KubernetesRequest.cs
new file mode 100644
index 000000000..4662b4f6e
--- /dev/null
+++ b/src/KubernetesClient/Fluent/KubernetesRequest.cs
@@ -0,0 +1,686 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using k8s.Models;
+using Microsoft.Rest;
+using Newtonsoft.Json;
+
+namespace k8s.Fluent
+{
+ /// Represents a single request to Kubernetes.
+ public sealed class KubernetesRequest : ICloneable
+ {
+ /// Initializes a based on a .
+ public KubernetesRequest(Kubernetes client)
+ {
+ if (client == null) throw new ArgumentNullException(nameof(client));
+ (baseUri, credentials, this.client) = (client.BaseUri.ToString(), client.Credentials, client.HttpClient);
+ Scheme(client.Scheme);
+ }
+
+ /// Initializes a based on a and
+ /// an .
+ ///
+ /// The used to connect to Kubernetes
+ /// The used to make the request, or null to use the default client. The default
+ /// is null.
+ ///
+ /// The used to map types to their Kubernetes group, version, and kind, or
+ /// null to use the default scheme. The default is null.
+ ///
+ /// Any necessary SSL configuration must have already been applied to the .
+ public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client = null, KubernetesScheme scheme = null)
+ {
+ if (config == null) throw new ArgumentNullException(nameof(config));
+ this.baseUri = config.Host;
+ if (string.IsNullOrEmpty(this.baseUri)) throw new ArgumentException(nameof(config)+".Host");
+ credentials = Kubernetes.CreateCredentials(config);
+ this.client = client ?? new HttpClient();
+ Scheme(scheme);
+ }
+
+ /// Initializes a based on a and an .
+ /// The absolute base URI of the Kubernetes API server
+ /// The used to connect to Kubernetes, or null if no credentials
+ /// of that type are needed. The default is null.
+ ///
+ /// The used to make the request, or null to use the default client. The default
+ /// is null.
+ ///
+ /// The used to map types to their Kubernetes group, version, and kind, or
+ /// null to use the default scheme. The default is null.
+ ///
+ /// Any necessary SSL configuration must have already been applied to the .
+ public KubernetesRequest(Uri baseUri, ServiceClientCredentials credentials = null, HttpClient client = null, KubernetesScheme scheme = null)
+ {
+ if (baseUri == null) throw new ArgumentNullException(nameof(baseUri));
+ if (!baseUri.IsAbsoluteUri) throw new ArgumentException("The base URI must be absolute.", nameof(baseUri));
+ (this.baseUri, this.credentials, this.client) = (baseUri.ToString(), credentials, client = client ?? new HttpClient());
+ Scheme(scheme);
+ }
+
+ /// Gets the value of the Accept header, or null to use the default of application/json.
+ public string Accept() => _accept;
+
+ /// Sets the value of the Accept header, or null or empty to use the default of application/json.
+ public KubernetesRequest Accept(string mediaType) { _accept = NormalizeEmpty(mediaType); return this; }
+
+ /// Adds a header to the request. Multiple header values with the same name can be set this way.
+ public KubernetesRequest AddHeader(string key, string value) => Add(ref headers, CheckHeaderName(key), value);
+
+ /// Adds a header to the request.
+ public KubernetesRequest AddHeader(string key, IEnumerable values) => Add(ref headers, CheckHeaderName(key), values);
+
+ /// Adds a header to the request.
+ public KubernetesRequest AddHeader(string key, params string[] values) => Add(ref headers, CheckHeaderName(key), values);
+
+ /// Adds a query-string parameter to the request. Multiple parameters with the same name can be set this way.
+ public KubernetesRequest AddQuery(string key, string value) => Add(ref query, key, value);
+
+ /// Adds query-string parameters to the request.
+ public KubernetesRequest AddQuery(string key, IEnumerable values) => Add(ref query, key, values);
+
+ /// Adds query-string parameters to the request.
+ public KubernetesRequest AddQuery(string key, params string[] values) => Add(ref query, key, values);
+
+ /// Gets the body to be sent to the server.
+ public object Body() => _body;
+
+ /// Sets the body to be sent to the server. If null, no body will be sent. If a string, byte array, or stream, the
+ /// contents will be sent directly. Otherwise, the body will be serialized into JSON and sent.
+ ///
+ public KubernetesRequest Body(object body) { _body = body; return this; }
+
+ /// Clears custom header values with the given name.
+ public KubernetesRequest ClearHeader(string headerName)
+ {
+ if (headerName == null) throw new ArgumentNullException(nameof(headerName));
+ CheckHeaderName(headerName);
+ if (headers != null) headers.Remove(headerName);
+ return this;
+ }
+
+ /// Clears all custom header values.
+ public KubernetesRequest ClearHeaders()
+ {
+ if (headers != null) headers.Clear();
+ return this;
+ }
+
+ /// Clears all query-string parameters.
+ public KubernetesRequest ClearQuery()
+ {
+ if (query != null) query.Clear();
+ return this;
+ }
+
+ /// Clears all query-string parameters with the given key.
+ public KubernetesRequest ClearQuery(string key)
+ {
+ if (key == null) throw new ArgumentNullException(nameof(key));
+ if (query != null) query.Remove(key);
+ return this;
+ }
+
+ /// Creates a deep copy of the .
+ public KubernetesRequest Clone()
+ {
+ var clone = (KubernetesRequest)MemberwiseClone();
+ if (headers != null)
+ {
+ clone.headers = new Dictionary>(headers.Count);
+ foreach (KeyValuePair> pair in headers) clone.headers.Add(pair.Key, new List(pair.Value));
+ }
+ if (query != null)
+ {
+ clone.query = new Dictionary>(query.Count);
+ foreach (KeyValuePair> pair in query) clone.query.Add(pair.Key, new List(pair.Value));
+ }
+ return clone;
+ }
+
+ /// Sets the to .
+ public KubernetesRequest Delete() => Method(HttpMethod.Delete);
+
+ /// Sets the to .
+ public KubernetesRequest Get() => Method(HttpMethod.Get);
+
+ /// Sets the to .
+ public KubernetesRequest Patch() => Method(new HttpMethod("PATCH"));
+
+ /// Sets the to .
+ public KubernetesRequest Post() => Method(HttpMethod.Post);
+
+ /// Sets the to .
+ public KubernetesRequest Put() => Method(HttpMethod.Put);
+
+ /// Sets the value of the "dryRun" query-string parameter, as a boolean.
+ public bool DryRun() => !string.IsNullOrEmpty(GetQuery("dryRun"));
+
+ /// Sets the value of the "dryRun" query-string parameter to "All" or removes it.
+ public KubernetesRequest DryRun(bool dryRun) => SetQuery("dryRun", dryRun ? "All" : null);
+
+ /// Executes the request and returns a . The request can be executed multiple times,
+ /// and can be executed multiple times in parallel.
+ ///
+ public async Task ExecuteAsync(CancellationToken cancelToken = default)
+ {
+ cancelToken.ThrowIfCancellationRequested();
+ HttpRequestMessage req = await CreateRequestMessage(cancelToken).ConfigureAwait(false);
+ // requests like watches may not send a body immediately, so return as soon as we've got the response headers
+ var completion = _streamResponse || _watchVersion != null ?
+ HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead;
+ return new KubernetesResponse(await client.SendAsync(req, completion, cancelToken).ConfigureAwait(false));
+ }
+
+ /// Executes the request and returns the deserialized response body (or the default value of type
+ /// if the response was 404 Not Found).
+ ///
+ /// Thrown if the response was any error besides 404 Not Found.
+ public Task ExecuteAsync(CancellationToken cancelToken = default) => ExecuteAsync(false, cancelToken);
+
+ /// Executes the request and returns the deserialized response body.
+ /// If true and the response is 404 Not Found, an exception will be thrown. If false, the default
+ /// value of type will be returned in that case. The default is false.
+ ///
+ /// A that can be used to cancel the request
+ /// Thrown if the response was any error besides 404 Not Found.
+ public async Task ExecuteAsync(bool failIfMissing, CancellationToken cancelToken = default)
+ {
+ if (_watchVersion != null) throw new InvalidOperationException("Watch requests cannot be deserialized all at once.");
+ cancelToken.ThrowIfCancellationRequested();
+ HttpRequestMessage reqMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false);
+ return await ExecuteMessageAsync(reqMsg, failIfMissing, cancelToken).ConfigureAwait(false);
+ }
+
+ /// Gets the "fieldManager" query-string parameter, or null if there is no field manager.
+ public string FieldManager() => NormalizeEmpty(GetQuery("fieldManager"));
+
+ /// Sets the "fieldManager" query-string parameter, or removes it if the value is null or empty.
+ public KubernetesRequest FieldManager(string manager) =>
+ SetQuery("fieldManager", !string.IsNullOrEmpty(manager) ? manager : null);
+
+ /// Gets the "fieldSelector" query-string parameter, or null if there is no field selector.
+ public string FieldSelector() => NormalizeEmpty(GetQuery("fieldSelector"));
+
+ /// Sets the "fieldSelector" query-string parameter, or removes it if the selector is null or empty.
+ public KubernetesRequest FieldSelector(string selector) =>
+ SetQuery("fieldSelector", !string.IsNullOrEmpty(selector) ? selector : null);
+
+ /// Gets the value of the named custom header, or null if it doesn't exist.
+ /// Thrown if there are multiple custom headers with the given name
+ public string GetHeader(string key)
+ {
+ List values = null;
+ if (headers != null) headers.TryGetValue(key, out values);
+ return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] :
+ throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'.");
+ }
+
+ /// Gets the values of the named custom header, or null if it has no values.
+ /// The returned collection, if not null, can be mutated to change the set of values.
+ public List GetHeaderValues(string key)
+ {
+ List values = null;
+ if (headers != null) headers.TryGetValue(key, out values);
+ return values;
+ }
+
+ /// Gets the value of the named query-string parameter, or null if it doesn't exist.
+ /// Thrown if there are multiple query-string parameters with the given name
+ public string GetQuery(string key)
+ {
+ List values = GetQueryValues(key);
+ return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] :
+ throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'.");
+ }
+
+ /// Gets the values of the named query-string parameter, or null if it has no values.
+ /// The returned collection, if not null, can be mutated to change the set of values.
+ public List GetQueryValues(string key)
+ {
+ List values = null;
+ if (query != null) query.TryGetValue(key, out values);
+ return values;
+ }
+
+ /// Gets the Kubernetes API group to use, or null or empty to use the default, which is the core API group
+ /// unless a is given.
+ ///
+ public string Group() => _group;
+
+ /// Sets the Kubernetes API group to use, or null or empty to use the default, which is the core API group
+ /// unless a is given.
+ ///
+ public KubernetesRequest Group(string group) { _group = NormalizeEmpty(group); return this; }
+
+ /// Attempts to set the , , and based on an object.
+ /// The method calls with the object's type. Then, if
+ /// is set, it will override and .
+ ///
+ public KubernetesRequest GVK(IKubernetesObject obj)
+ {
+ if (obj == null) throw new ArgumentNullException();
+ GVK(obj.GetType());
+ if (!string.IsNullOrEmpty(obj.ApiVersion)) // if the object has an API version set, use it...
+ {
+ int slash = obj.ApiVersion.IndexOf('/'); // the ApiVersion field is in the form "version" or "group/version"
+ Group(slash >= 0 ? obj.ApiVersion.Substring(0, slash) : null).Version(obj.ApiVersion.Substring(slash+1));
+ }
+ return this;
+ }
+
+ /// Attempts to set the , , and based on a Kubernetes
+ /// group, version, and kind. The method uses heuristics and may not work in all cases.
+ ///
+ public KubernetesRequest GVK(string group, string version, string kind) =>
+ Group(!string.IsNullOrEmpty(group) ? group : null).Version(!string.IsNullOrEmpty(version) ? version : null)
+ .Type(KubernetesScheme.GuessPath(kind));
+
+ /// Attempts to set the , , and based on a type of object,
+ /// such as .
+ ///
+ public KubernetesRequest GVK(Type type)
+ {
+ if (type == null) throw new ArgumentNullException(nameof(type));
+ _scheme.GetGVK(type, out string group, out string version, out string kind, out string path);
+ return Group(NormalizeEmpty(group)).Version(version).Type(path);
+ }
+
+ /// Attempts to set the , , and based on a type of object,
+ /// such as .
+ ///
+ public KubernetesRequest GVK() => GVK(typeof(T));
+
+ /// Gets the "labelSelector" query-string parameter, or null if there is no label selector.
+ public string LabelSelector() => NormalizeEmpty(GetQuery("labelSelector"));
+
+ /// Sets the "labelSelector" query-string parameter, or removes it if the selecor is null or empty.
+ public KubernetesRequest LabelSelector(string selector) =>
+ SetQuery("labelSelector", !string.IsNullOrEmpty(selector) ? selector : null);
+
+ /// Gets the value of the Content-Type header, or null to use the default of application/json.
+ public string MediaType() => _mediaType;
+
+ /// Sets the value of the Content-Type header, not including any parameters, or null or empty to use the default
+ /// of application/json. The header value will only be used if a is supplied.
+ ///
+ public KubernetesRequest MediaType(string mediaType) { _mediaType = NormalizeEmpty(mediaType); return this; }
+
+ /// Gets the to use.
+ public HttpMethod Method() => _method ?? HttpMethod.Get;
+
+ /// Sets the to use, or null to use the default of .
+ public KubernetesRequest Method(HttpMethod method) { _method = method; return this; }
+
+ /// Gets the name of the top-level Kubernetes resource to access.
+ public string Name() => _name;
+
+ /// Sets the name of the top-level Kubernetes resource to access, or null or empty to not access a specific object.
+ public KubernetesRequest Name(string name) { _name = NormalizeEmpty(name); return this; }
+
+ /// Gets the Kubernetes namespace to access.
+ public string Namespace() => _ns;
+
+ /// Sets the Kubernetes namespace to access, or null or empty to not access a namespaced object.
+ public KubernetesRequest Namespace(string ns) { _ns = NormalizeEmpty(ns); return this; }
+
+ /// Gets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or
+ /// null if the URL will be constructed piecemeal based on the other properties.
+ ///
+ public string RawUri() => _rawUri;
+
+ /// Sets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or
+ /// null or empty to construct the URI piecemeal based on the other properties. The URI must begin with a slash.
+ ///
+ public KubernetesRequest RawUri(string uri)
+ {
+ uri = NormalizeEmpty(uri);
+ if (uri != null && uri[0] != '/') throw new ArgumentException("The URI must begin with a slash.");
+ _rawUri = uri;
+ return this;
+ }
+
+ /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to
+ /// replace it.
+ ///
+ /// A function that modifies the resource, returning true if any changes were made and false if not
+ /// If true, an exception will be thrown if the object doesn't exist. If false, null will be
+ /// returned in that case.
+ ///
+ /// A that can be used to cancel the request
+ public Task ReplaceAsync(Func update, bool failIfMissing = false, CancellationToken cancelToken = default)
+ where T : class, IMetadata => ReplaceAsync(null, update, failIfMissing, cancelToken);
+
+ /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to
+ /// replace it.
+ ///
+ /// A function that modifies the resource, returning true if any changes were made and false if not
+ /// If true, an exception will be thrown if the object doesn't exist. If false, null will be
+ /// returned in that case.
+ ///
+ /// A that can be used to cancel the request
+ public Task ReplaceAsync(
+ Func> update, bool failIfMissing = false, CancellationToken cancelToken = default)
+ where T : class, IMetadata => ReplaceAsync(null, update, failIfMissing, cancelToken);
+
+ /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to
+ /// replace it.
+ ///
+ /// The initial value of the resource, or null if it should be retrieved with a GET request
+ /// A function that modifies the resource, returning true if any changes were made and false if not
+ /// If true, an exception will be thrown if the object doesn't exist. If false, null will be
+ /// returned in that case.
+ ///
+ /// A that can be used to cancel the request
+ public Task ReplaceAsync(T obj, Func modify, bool failIfMissing = false, CancellationToken cancelToken = default)
+ where T : class
+ {
+ if (modify == null) throw new ArgumentNullException(nameof(modify));
+ return ReplaceAsync(obj, (o,ct) => Task.FromResult(modify(o)), failIfMissing, cancelToken);
+ }
+
+ /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to
+ /// replace it.
+ ///
+ /// The initial value of the resource, or null if it should be retrieved with a GET request
+ /// A function that modifies the resource, returning true if any changes were made and false if not
+ /// If true, an exception will be thrown if the object doesn't exist. If false, null will be
+ /// returned in that case.
+ ///
+ /// A that can be used to cancel the request
+ public async Task ReplaceAsync(
+ T obj, Func> modify, bool failIfMissing = false, CancellationToken cancelToken = default)
+ where T : class
+ {
+ if (modify == null) throw new ArgumentNullException(nameof(modify));
+ if (_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated.");
+ KubernetesRequest req = Clone();
+ while(true)
+ {
+ if(obj == null) // if we need to load the resource...
+ {
+ cancelToken.ThrowIfCancellationRequested(); // load it with a GET request
+ obj = await req.Get().Body(null).ExecuteAsync(failIfMissing, cancelToken).ConfigureAwait(false);
+ }
+ cancelToken.ThrowIfCancellationRequested();
+ // if the resource is missing or no changes are needed, return it as-is. otherwise, update it with a PUT request
+ if(obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj;
+ KubernetesResponse resp = await req.Put().Body(obj).ExecuteAsync(cancelToken).ConfigureAwait(false);
+ if(resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result
+ {
+ if(resp.IsNotFound && !failIfMissing) return null;
+ else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false));
+ else return await resp.GetBodyAsync().ConfigureAwait(false);
+ }
+ obj = null; // otherwise, there was a conflict, so reload the item
+ }
+ }
+
+ /// Gets the used to map types to their Kubernetes groups, version, and kinds.
+ public KubernetesScheme Scheme() => _scheme;
+
+ /// Sets the used to map types to their Kubernetes groups, version, and kinds, or null to
+ /// use the scheme.
+ ///
+ public KubernetesRequest Scheme(KubernetesScheme scheme) { _scheme = scheme ?? KubernetesScheme.Default; return this; }
+
+ /// Attempts to set the , , , ,
+ /// , and optionally the based on the given object.
+ ///
+ /// If the object implements of , it will be used to set the
+ /// and . The will be set if
+ /// is set (on the assumption that you're accessing an existing object), and cleared it's clear (on the assumption that you're
+ /// creating a new object and want to POST to its container).
+ ///
+ public KubernetesRequest Set(IKubernetesObject obj, bool setBody = true)
+ {
+ GVK(obj);
+ if (setBody) Body(obj);
+ var kobj = obj as IMetadata;
+ if (kobj != null) Namespace(kobj.Namespace()).Name(!string.IsNullOrEmpty(kobj.Uid()) ? kobj.Name() : null);
+ return this;
+ }
+
+ /// Sets a custom header value, or deletes it if the value is null.
+ public KubernetesRequest SetHeader(string headerName, string value) => Set(ref headers, CheckHeaderName(headerName), value);
+
+ /// Sets a query-string value, or deletes it if the value is null.
+ public KubernetesRequest SetQuery(string key, string value) => Set(ref query, key, value);
+
+ /// Sets the to "status", to get or set a resource's status.
+ public KubernetesRequest Status() => Subresource("status");
+
+ /// Gets whether the response must be streamed. If true, the response will be returned from
+ /// as soon as the headers are read and you will have to dispose the response. Otherwise, the entire response will be downloaded
+ /// before returns, and you will not have to dispose it. Note that regardless of the
+ /// value of this property, the response is always streamed when is not null.
+ ///
+ public bool StreamResponse() => _streamResponse;
+
+ /// Sets whether the response must be streamed. If true, the response will be returned from
+ /// as soon as the headers are read and you will have to dispose the response. Otherwise, the entire response will be downloaded
+ /// before returns, and you will not have to dispose it. The default is false. Note that regardless of the
+ /// value of this property, the response is always streamed when is not null.
+ ///
+ public KubernetesRequest StreamResponse(bool stream) { _streamResponse = stream; return this; }
+
+ /// Gets the URL-encoded subresource to access, or null to not access a subresource.
+ public string Subresource() => _subresource;
+
+ /// Sets the subresource to access, or null or empty to not access a subresource. The value must be URL-encoded
+ /// already if necessary.
+ ///
+ public KubernetesRequest Subresource(string subresource) { _subresource = NormalizeEmpty(subresource); return this; }
+
+ /// Sets the value of the by joining together one or more path segments. The
+ /// segments will be URL-escaped (and so should not be URL-escaped already).
+ ///
+ public KubernetesRequest Subresources(params string[] subresources) =>
+ Subresource(subresources != null && subresources.Length != 0 ?
+ string.Join("/", subresources.Select(Uri.EscapeDataString)) : null);
+
+ ///
+ public override string ToString() => Method().Method + " " + GetRequestUri();
+
+ /// Gets the resource type access (e.g. "pods").
+ public string Type() => _type;
+
+ /// Sets the resource type access (e.g. "pods").
+ public KubernetesRequest Type(string type) { _type = NormalizeEmpty(type); return this; }
+
+ /// Gets the Kubernetes API version to use, or null to use the default, which is "v1"
+ /// unless a is given.
+ ///
+ public string Version() => _version;
+
+ /// Sets the Kubernetes API version to use, or null or empty to use the default, which is "v1"
+ /// unless a is given.
+ ///
+ public KubernetesRequest Version(string version) { _version = NormalizeEmpty(version); return this; }
+
+ /// Gets the resource version to use when watching a resource, or empty to watch the current version, or null
+ /// to not execute a watch.
+ ///
+ public string WatchVersion() => _watchVersion;
+
+ /// Sets the resource version to use when watching a resource, or empty to watch the current version, or null to not
+ /// execute a watch. The default is null. When set, the response is always streamed (as though
+ /// was true).
+ ///
+ public KubernetesRequest WatchVersion(string resourceVersion) { _watchVersion = resourceVersion; return this; }
+
+ /// Adds a value to the query string or headers.
+ KubernetesRequest Add(ref Dictionary> dict, string key, string value)
+ {
+ if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key));
+ if (dict == null) dict = new Dictionary>();
+ if (!dict.TryGetValue(key, out List values)) dict[key] = values = new List();
+ values.Add(value);
+ return this;
+ }
+
+ /// Adds a value to the query string or headers.
+ KubernetesRequest Add(ref Dictionary> dict, string key, IEnumerable values)
+ {
+ if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key));
+ if (values != null)
+ {
+ if (dict == null) dict = new Dictionary>();
+ if (!dict.TryGetValue(key, out List list)) dict[key] = list = new List();
+ list.AddRange(values);
+ }
+ return this;
+ }
+
+ /// Sets a value in the query string or headers.
+ KubernetesRequest Set(ref Dictionary> dict, string key, string value)
+ {
+ if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key));
+ dict = dict ?? new Dictionary>();
+ if (!dict.TryGetValue(key, out List values)) dict[key] = values = new List();
+ values.Clear();
+ values.Add(value);
+ return this;
+ }
+
+ /// Creates an representing the current request.
+#if NETCOREAPP2_1
+ async ValueTask CreateRequestMessage(CancellationToken cancelToken)
+#else
+ async Task CreateRequestMessage(CancellationToken cancelToken)
+#endif
+ {
+ var req = new HttpRequestMessage(Method(), GetRequestUri());
+ if (credentials != null) await credentials.ProcessHttpRequestAsync(req, cancelToken).ConfigureAwait(false);
+
+ // add the headers
+ if (_accept != null) req.Headers.Add("Accept", _accept);
+ List>> contentHeaders = null;
+ if (headers != null && headers.Count != 0) // add custom headers
+ {
+ contentHeaders = new List>>(); // some headers must be added to .Content.Headers. track them
+ foreach (KeyValuePair> pair in headers)
+ {
+ if (!req.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if it's not legal to set this header on the request...
+ {
+ contentHeaders.Add(new KeyValuePair>(pair.Key, pair.Value)); // assume we should set it on the content
+ break;
+ }
+ }
+ }
+
+ // add the body, if any
+ if (_body != null)
+ {
+ if (_body is byte[] bytes) req.Content = new ByteArrayContent(bytes);
+ else if (_body is Stream stream) req.Content = new StreamContent(stream);
+ else
+ {
+ req.Content = new StringContent(
+ _body as string ?? JsonConvert.SerializeObject(_body, Kubernetes.DefaultJsonSettings), Encoding.UTF8);
+ }
+ req.Content.Headers.ContentType = new MediaTypeHeaderValue(_mediaType ?? "application/json") { CharSet = "UTF-8" };
+ if (contentHeaders != null && contentHeaders.Count != 0) // go through the headers we couldn't set on the request
+ {
+ foreach (KeyValuePair> pair in contentHeaders)
+ {
+ if (!req.Content.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if we can't set it on the content either...
+ {
+ throw new InvalidOperationException($"{pair.Value} is a response header and cannot be set on the request.");
+ }
+ }
+ }
+ }
+ return req;
+ }
+
+ async Task ExecuteMessageAsync(HttpRequestMessage msg, bool failIfMissing, CancellationToken cancelToken)
+ {
+ cancelToken.ThrowIfCancellationRequested();
+ KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(msg, cancelToken).ConfigureAwait(false));
+ if (resp.IsNotFound && !failIfMissing) return default(T);
+ else if (resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false));
+ else return await resp.GetBodyAsync().ConfigureAwait(false);
+ }
+
+ string GetRequestUri()
+ {
+ if (_rawUri != null && (_group ?? _name ?? _ns ?? _subresource ?? _type ?? _version) != null)
+ {
+ throw new InvalidOperationException("You cannot use both raw and piecemeal URIs.");
+ }
+
+ // construct the request URL
+ var sb = new StringBuilder();
+ sb.Append(baseUri);
+ if (_rawUri != null) // if a raw URL was given, use it
+ {
+ if (sb[sb.Length-1] == '/') sb.Length--; // the raw URI starts with a slash, so ensure the base URI doesn't end with one
+ sb.Append(_rawUri);
+ }
+ else // otherwise, construct it piecemeal
+ {
+ if (sb[sb.Length-1] != '/') sb.Append('/'); // ensure the base URI ends with a slash
+ if (_group != null) sb.Append("apis/").Append(_group);
+ else sb.Append("api");
+ sb.Append('/').Append(_version ?? "v1");
+ if (_ns != null) sb.Append("/namespaces/").Append(_ns);
+ sb.Append('/').Append(_type);
+ if (_name != null) sb.Append('/').Append(_name);
+ if (_subresource != null) sb.Append('/').Append(_subresource);
+ }
+ bool firstParam = true;
+ if (query != null) // then add the query string, if any
+ {
+ foreach (KeyValuePair> pair in query)
+ {
+ string key = Uri.EscapeDataString(pair.Key);
+ foreach (string value in pair.Value)
+ {
+ sb.Append(firstParam ? '?' : '&').Append(key).Append('=');
+ if (!string.IsNullOrEmpty(value)) sb.Append(Uri.EscapeDataString(value));
+ firstParam = false;
+ }
+ }
+ }
+ if (_watchVersion != null)
+ {
+ sb.Append(firstParam ? '?' : '&').Append("watch=1");
+ if (_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion);
+ }
+ return sb.ToString();
+ }
+
+ object ICloneable.Clone() => Clone();
+
+ readonly HttpClient client;
+ readonly string baseUri;
+ readonly ServiceClientCredentials credentials;
+ Dictionary> headers, query;
+ string _accept = "application/json", _mediaType = "application/json";
+ string _group, _name, _ns, _rawUri, _subresource, _type, _version, _watchVersion;
+ object _body;
+ HttpMethod _method;
+ KubernetesScheme _scheme;
+ bool _streamResponse;
+
+ static string CheckHeaderName(string name)
+ {
+ if (name == "Accept" || name == "Content-Type")
+ {
+ throw new ArgumentException($"The {name} header must be set using the corresponding property.");
+ }
+ return name;
+ }
+
+ static string NormalizeEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // normalizes empty strings to null
+ }
+}
diff --git a/src/KubernetesClient/Fluent/KubernetesResponse.cs b/src/KubernetesClient/Fluent/KubernetesResponse.cs
new file mode 100644
index 000000000..3d6c0ff87
--- /dev/null
+++ b/src/KubernetesClient/Fluent/KubernetesResponse.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using k8s.Models;
+using Newtonsoft.Json;
+
+namespace k8s.Fluent
+{
+ /// Represents a response to a .
+ public sealed class KubernetesResponse : IDisposable
+ {
+ /// Initializes a new from an .
+ public KubernetesResponse(HttpResponseMessage message) => Message = message ?? throw new ArgumentNullException(nameof(message));
+
+ /// Indicates whether the server returned an error response.
+ public bool IsError => (int)StatusCode >= 400;
+
+ /// Indicates whether the server returned a 404 Not Found response.
+ public bool IsNotFound => StatusCode == HttpStatusCode.NotFound;
+
+ /// Gets the underlying .
+ public HttpResponseMessage Message { get; }
+
+ /// Gets the of the response.
+ public HttpStatusCode StatusCode => Message.StatusCode;
+
+ ///
+ public void Dispose() => Message.Dispose();
+
+ /// Returns the response body as a string.
+ public async Task GetBodyAsync()
+ {
+ if (body == null)
+ {
+ body = Message.Content != null ? await Message.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty;
+ }
+ return body;
+ }
+
+ /// Deserializes the response body from JSON as a value of the given type, or null if the response body is empty.
+ /// The type of object to return
+ /// If false, an empty response body will be returned as null. If true, an exception will be thrown if
+ /// the body is empty. The default is false.
+ ///
+ public async Task