Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add headers support #65

Merged
merged 24 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1436aa7
Add headers store interface with default in-memory store.
calebkiage Aug 21, 2023
943294e
Add http headers handler that will work with System.Net.Http.HttpClient
calebkiage Aug 21, 2023
6ab1da0
Fix param refs
calebkiage Aug 21, 2023
bb36310
Add an extension function that will enable headers support on an inst…
calebkiage Aug 21, 2023
b1f97ee
Add test for RegisterHeadersOption
calebkiage Aug 21, 2023
d736405
Add assertions for commands with no handlers
calebkiage Aug 21, 2023
5aae5b4
Update package version
calebkiage Aug 21, 2023
80ee2b1
Cleanup code.
calebkiage Aug 22, 2023
3a29969
Add a concurrent in-memory header store for multi-threaded clients.
calebkiage Aug 22, 2023
3e54a6e
Use case-insensitive content header check
calebkiage Aug 22, 2023
ebfcf96
handle null headers collection in the header store API
calebkiage Aug 22, 2023
1d7e728
Use single InMemoryHeadersStore class with parameter for thread safety.
calebkiage Aug 23, 2023
7bee4da
Add note for ParseHeaders invariant.
calebkiage Aug 23, 2023
b1d6b33
Use read-only auto-property for IsConcurrent
calebkiage Aug 23, 2023
f7c2568
Add unit tests for NativeHttpHeadersHandler.cs
calebkiage Aug 23, 2023
aa1dc40
minor refactor
calebkiage Aug 23, 2023
c8fd5f4
Validate empty option name in RegisterHeaderOptions helper.
calebkiage Aug 23, 2023
8813c8d
Add more tests for RegisterHeadersOption convenience function
calebkiage Aug 23, 2023
9481dad
Add more tests for header store
calebkiage Aug 23, 2023
b7becc3
Use string constants for content headers
calebkiage Aug 24, 2023
95e5d36
Add extra null check on ParseHeaders function
calebkiage Aug 28, 2023
c3f0414
Fix sonarcloud warning
calebkiage Aug 28, 2023
e7f94fe
Remove ConcurrentInstance singleton property.
calebkiage Aug 31, 2023
29d5335
Merge branch 'main' into feat/headers-support
calebkiage Aug 31, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"dotnet.defaultSolution": "Microsoft.Kiota.Cli.Commons.sln"
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Cli.Commons.Extensions;
using Microsoft.Kiota.Cli.Commons.Http.Headers;
using Microsoft.Kiota.Cli.Commons.IO;
using Moq;
using Xunit;
Expand Down Expand Up @@ -74,4 +76,33 @@ public async Task RegisterCommonServicesRegistersServicesAsync()
Assert.NotNull(foundPagingSvc);
Assert.IsType<ODataPagingService>(foundPagingSvc);
}

[Fact]
public async Task RegisterRegisterHeadersOptionTest()
{
var storeMock = new Mock<IHeadersStore>();
ICollection<string>? givenHeaders = null;
storeMock.Setup(m => m.SetHeaders(It.IsAny<ICollection<string>>()))
.Returns((ICollection<string> headers) =>
{
givenHeaders = headers;
return Enumerable.Empty<KeyValuePair<string, ICollection<string>>>();
});
var command = new Command("test");
var subcmd = new Command("sub");
command.Add(subcmd);
subcmd.SetHandler(_ => {
});
var parser = new CommandLineBuilder(command)
.RegisterHeadersOption(() => storeMock.Object)
.Build();

var result = parser.Parse("test sub --headers a=b");
await result.InvokeAsync();

Assert.NotNull(givenHeaders);
Assert.Equal("a=b", givenHeaders.FirstOrDefault());
Assert.Empty(command.Options);
Assert.Single(subcmd.Options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System;
using System.Linq;
using Microsoft.Kiota.Cli.Commons.Http.Headers;
using Xunit;

namespace Microsoft.Kiota.Cli.Commons.Tests.Http.Headers;

public class InMemoryHeaderStoreTests
{
public class SetHeadersFunction
{
[Fact]
public void Stores_Single_Header()
{
var header = new [] {"sample=header"};
var store = new InMemoryHeadersStore();
store.SetHeaders(header);

Assert.Single(store.GetHeaders());
Assert.Single(store.GetHeaders().First().Value);
}

[Fact]
public void Stores_Multiple_Headers()
{
var header = new [] {"sample=header", "sample2=header2", };
var store = new InMemoryHeadersStore();
store.SetHeaders(header);

Assert.NotEmpty(store.GetHeaders());
Assert.Equal(2, store.GetHeaders().Count());
Assert.Single(store.GetHeaders().First().Value);
}

[Fact]
public void Stores_Multiple_Headers_With_Matching_Key()
{
var header = new [] {"sample=header", "sample=header2", };
var store = new InMemoryHeadersStore();
store.SetHeaders(header);

Assert.NotEmpty(store.GetHeaders());
Assert.Single(store.GetHeaders());
Assert.Equal(2, store.GetHeaders().First().Value.Count);
}

[Fact]
public void Clears_Existing_Headers()
{
var header = new [] {"sample=header", "sample2=header2", };
var store = new InMemoryHeadersStore();
store.SetHeaders(header);

var result = store.SetHeaders(new[] { "sample3=header3" });

Assert.Equal(2, result.Count());
Assert.Single(store.GetHeaders());
Assert.Single(store.GetHeaders().First().Value);

result = store.SetHeaders(Array.Empty<string>());
Assert.Single(result);
Assert.Empty(store.GetHeaders());
}
}

public class AddHeadersFunction
{
[Fact]
public void Adds_To_Existing_Headers()
{
var header = new [] {"sample=header", "sample2=header2", };
var store = new InMemoryHeadersStore();
store.SetHeaders(header);

Assert.Equal(2, store.GetHeaders().Count());
Assert.True(store.AddHeaders(new[] { "sample3=header3" }));
Assert.Equal(3, store.GetHeaders().Count());
Assert.False(store.AddHeaders(Array.Empty<string>()));
Assert.Equal(3, store.GetHeaders().Count());
}
}

public class DrainFunction
{
[Fact]
public void Clears_Existing_Headers()
{
var header = new [] {"sample=header", "sample2=header2", };
var store = new InMemoryHeadersStore();
store.AddHeaders(header);

Assert.Equal(2, store.GetHeaders().Count());

var result = store.Drain();

Assert.Equal(2, result.Count());
Assert.Empty(store.GetHeaders());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using System;
using System.CommandLine;
using System.CommandLine.Binding;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Cli.Commons.Http.Headers;
using Microsoft.Kiota.Cli.Commons.IO;

namespace Microsoft.Kiota.Cli.Commons.Extensions;
Expand Down Expand Up @@ -34,7 +35,7 @@ public static CommandLineBuilder UseRequestAdapter(this CommandLineBuilder build
/// <summary>
/// Registers an instance of IRequestAdapter.
/// </summary>
public static CommandLineBuilder UseRequestAdapter(this CommandLineBuilder builder, [NotNull] IRequestAdapter requestAdapter)
public static CommandLineBuilder UseRequestAdapter(this CommandLineBuilder builder, IRequestAdapter requestAdapter)
{
builder.AddMiddleware(async (context, next) =>
{
Expand Down Expand Up @@ -69,4 +70,74 @@ public static CommandLineBuilder RegisterCommonServices(this CommandLineBuilder
});
return builder;
}

/// <summary>
/// Registers a headers option to all executable commands in the builder
/// and a middleware that reads options from the parsed result and adds
/// them to the store instance returned after calling
/// <paramref name="headersStoreGetter"/>.
/// </summary>
///
/// <param name="builder">A command line builder</param>
/// <param name="headersStoreGetter">
/// A delegate that will be called to get an instance of
/// <see cref="IHeadersStore"/>.
/// </param>
/// <param name="name">
/// A custom name for the registered option.
/// Defaults to <c>--headers</c>
/// </param>
/// <param name="customDescription">
/// An optional description for the registered headers option.
/// </param>
///
/// <returns>
/// The same instance of <see cref="CommandLineBuilder"/>.
/// </returns>
///
/// <remarks>
/// <para>
/// This function must be called after the root command has been set in
/// the <see cref="CommandLineBuilder"/>.
/// </para>
/// <para>
/// This function does nothing to add the headers to a request. You would
/// need to implement a way to read from the updated
calebkiage marked this conversation as resolved.
Show resolved Hide resolved
/// <see cref="IHeadersStore"/> and update the request. For an example that
/// works with <see cref="System.Net.Http.HttpClient"/>,
/// see the
/// <see cref="Http.NativeHttpHeadersHandler"/>
/// class.
/// </para>
/// </remarks>
public static CommandLineBuilder RegisterHeadersOption(this CommandLineBuilder builder, Func<IHeadersStore> headersStoreGetter, string name="--headers", string? customDescription = null)
{
// TODO: Check for empty name?
calebkiage marked this conversation as resolved.
Show resolved Hide resolved
var headersOption = new Option<string[]>(name, customDescription ?? $"Allows adding custom headers to the request. The option can be used multiple times to add multiple headers. e.g. --{name} key1=value1 --{name} key2=value2")
calebkiage marked this conversation as resolved.
Show resolved Hide resolved
{
Arity = ArgumentArity.ZeroOrMore
};

// Recursively adds the headers option to the commands with handlers starting with the root
AddOptionToCommandIf(builder.Command, headersOption, cmd => cmd.Handler is not null);

builder.AddMiddleware(async (ic, next) =>
{
// Add headers to the headers store.
headersStoreGetter().SetHeaders(ic.ParseResult.GetValueForOption(headersOption));
await next(ic);
});
return builder;
}

private static void AddOptionToCommandIf(Command command, in Option option, Func<Command, bool> predicate) {
if (predicate(command)) {
command.AddOption(option);
}

foreach (var cmd in command.Subcommands)
{
AddOptionToCommandIf(cmd, option, predicate);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace Microsoft.Kiota.Cli.Commons.Http.Headers;

/// <summary>
/// Base class for the <see cref="IHeadersStore"/> interface.
/// This type exists so all child classes can share the headers parsing logic.
/// </summary>
public abstract class BaseHeadersStore : IHeadersStore
{
/// <inheritdoc />
public abstract IEnumerable<KeyValuePair<string, ICollection<string>>> GetHeaders();

/// <inheritdoc />
/// <remarks>
/// Passing a null or empty collection of headers to this function has the
/// same effect as calling <see cref="Drain"/>
/// </remarks>
public virtual IEnumerable<KeyValuePair<string, ICollection<string>>> SetHeaders(ICollection<string>? headers)
{
var existing = Drain();
AddHeaders(headers);
return existing;
}

/// <inheritdoc />
/// <remarks>
/// If <paramref name="headers"/> collection is null, this function will
/// return false
/// The parsing logic for this type uses the <see cref="ParseHeaders"/>
/// function to parse the headers. Override ParseHeaders in sub-classes
/// to change the parsing behavior.
/// </remarks>
public virtual bool AddHeaders(ICollection<string>? headers)
{
if (headers is null || headers.Count < 1)
{
return false;
}

var m = ParseHeaders(headers);

foreach (var (key, value) in m)
{
DoAddHeader(key, value);
}

return true;
}

/// <inheritdoc />
public abstract IEnumerable<KeyValuePair<string, ICollection<string>>> Drain();

/// <summary>
/// Parses a collection of headers into a collection of key-value pairs
/// with the header name and value.
/// This function expects each header item to be in the format
/// <code>header-name=header-value</code>
/// This function does not do anything about duplicates, so if you passed
/// in <code>["a=1", "b=2"]</code> the result will be
/// <code>[{ "a": "1" }, { "b": "2" }]</code>
/// </summary>
/// <param name="headers">A collection of strings with header information.</param>
/// <returns>A collection of key-value pairs with parsed header information.</returns>
/// <remarks>Override this function to customize the parsing logic.</remarks>
protected virtual IEnumerable<KeyValuePair<string, string>> ParseHeaders(IEnumerable<string> headers)
{
// This function is called by AddHeaders which checks for null. If
// headers is null, then something went wrong
Debug.Assert(headers is not null);
calebkiage marked this conversation as resolved.
Show resolved Hide resolved
foreach (var headerLine in headers)
{
var split = headerLine.Split('=',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (split.Length < 2)
{
continue;
}

yield return new KeyValuePair<string, string>(split[0], split[1]);
}
}

/// <summary>
/// Implementation of logic to add headers to a headers store.
/// </summary>
/// <param name="name">The header name</param>
/// <param name="value">The header value</param>
/// <remarks>
/// This function exists to facilitate customizing the header storage
/// location.
/// For example, one might want to customize the data structure used to
/// store the headers for performance optimizations.
/// </remarks>
protected abstract void DoAddHeader(string name, string value);
}
Loading