If we have a problem that needs to keep API and CLI both in the same application, here is the solution to your niche problem. We have .Net Core applications at work that use CLI and API construction at the same time. Meaning that if your application binary can also get executed as Command Line Interface, then you need to branch to CLI handlers before you execute/create API handling. Example:
./MyCoolApp db create
The command should not go to WebHost
or Host
to create the .Net Core pipeline. It should invoke the related db create
command and exit with the proper exit code. So, the application I demonstrate has multiple commands like this.
So, finding a unified way of doing it becomes harder than you think. Eventually, we find the interaction between API and Commandline poorly connected because of the abstraction leaks or complexity of the composition. We can also question, “Do they need to sit together?” Well, you can easily say they are separate concerns, and I want to separate them, it is another topic of course. Still, if you are in a situation that you need to melt down these two separate concerns in the same pot, then you need to think of a solution to solve that.
The solution needs to cover all AspNet Core 3.1 functionalities, and also at least it should be able to use ServiceCollection to enable dependency injection. Because you would not want to register or pass your dependencies with the Pure DI approach for the sake of the command-line interface, well, I wouldn’t.
So, let’s collect some requirements along the way, what we said:
- Harmony with AspNet Core API handling
- Use dependency injection
- Don’t use Pure DI in anywhere, and don’t introduce any other DI container (which I encourage)
- Create also a unit test suite around the implementation
- (Optional) Create a validation before command gets executed
So, let’s create an application that suits our needs, and I explain my ideas better on a concrete example.
The Implementation Link to heading
The dependencies we have are:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.20214.1" />
<PackageReference Include="System.CommandLine.Hosting" Version="0.3.0-alpha.20214.1" />
</ItemGroup>
</Project>
As you can see, it is a .Net Core application that has dependencies on System.Commandline
and
System.Commandline.Hosting
.
We have a Program.cs
file that contains the application composition. It is straightforward, and basically
handles the branching of the application according to the ExitCode
as you can see at the lines 19-20
.
So, the critical part here is the ApplicationFactory
class that helps to test in the future also. Now, it is creating the application’s building blocks such as CommandLineBuilder
and WebHostBuilder
. The reason why we didn’t use the same ApplicationFactory
class in Main
is that AspnetCore testing needs the method called CreateHostBuilder
in Program.cs
to be able to create TestHost
. In other words, we’re going to use WebApplicationFactory<T>
class in testing, and this testing approach uses the entrance point of the application to create Asp.Net Core API pipeline. So, duplicating is fine.
Let’s come to ApplicationFactory
class to understand how it composes the WebHost
and CliHost
.
At first glance, we see two branches: the first is CreateWebHostBuilder
, and the second is CreateCommandLineBuilder
. They help to build the parts mentioned above of the application accurately. Creation of WebHost is straightforward, there is nothing, just Startup
that gets executed. On the other hand, for the CommandHandling, there are specific extensions that I created to build the pipeline shortly and understandably.
The other things that I can mention, ConfigureServices
and ConfigureApplication
actions are related to testing again. They help to add test dependencies or fakes to DI container. At line 52
there is UseHost
extension that enables NetCore middleware mechanism and changes the ApplicationLifetime
to invocation lifetime, which also enables the CTRL+C
interruption if you want to cancel your CLI handling with it.
So, let’s come back to the ApplicationFactory
class and find the statement of the creation of command handling. It is 50
, and with this extension new RootCommand().CreateCommandHandling()
, we create our entire command line handling. I explain the details later.
And last but not least, BranchToWebIfNoCommandToExecute()
middleware extension to enable the application to branch to Web if there is no applicable method found for the CLI.
Dependency Injection Link to heading
As you may recall in the ApplicationFactory
class, it uses the ConfigureServices
method to add dependencies to the ServiceCollection
which is the point where we define all dependencies that the command handling needs. Whatever added here is replaceable with action methods that are at the top of the class so that we can replace the implementations according to our test needs.
Extension Points Link to heading
I share the all extension class named CommandLineExtensions.cs.
at the end but, let’s look a bit method by method to understand what they do.
So, how we define the RootCommand
that can inject dependencies. Let’s look at a command creation with this approach.
Command version = new Command("version")
.Configure(c => { c.AddAlias("--version"); })
.HandledBy<GetVersionCommandLineHandler>();
What it tells us, there is a command called version
and has an alias --version
and HandledBy a handler. So, that’s it. DI enabled GetVersionCommandLineHandler
can inject any dependency on itself and use it, as long as they are registered in ServiceCollection
.
If we take a closer look at this extension method:
Here we see that it uses CommandHandler.Create
method to create a handler for the specific command. This method belongs to System.Commandline
package. Before the execution of the command, two things are happening. If the command is ValidatedCommand
, then validate it before execute it; while doing this, also use logger. The second is if there are any parsing errors, check them with if (invocation.HasErrors())
then return if something is wrong.
If everything is fine, then Invoke
the command by resolving the handler host.Services.GetRequiredService<T>().InvokeAsync(invocation);
. Which is the point where CLI handler’s ServiceProvider attempts to resolve the command’s handler.
ValidatedCommand Link to heading
One of the things that I need while handling the commands is validating it before the execution. When we use DI enabled strategy, validating all the commands in the same place would do the trick, therefore I implemented the ValidatedCommand
class to leverage this.
It essentially inherits System.Commandline
’s Command
class and adds some custom validation logic. And after that, HandledBy
takes care of all the validation execution before the command gets executed. Let’s demonstrate here:
|
|
As you may notice, c.AddValication(sp => { return (true, string.Empty); })
statement adds the validation, and good point here is, you’re able to use the ServiceProvider
that has been built by the CLI pipeline to facilitate your validation logic with it.
The Handler Link to heading
Finally, we get to inject the dependencies to the handlers, and everyone is happy. I take a basic handler example here, don’t forget to register the logger:)
It merely demonstrates a database migration handler which gets executed, let’s say before the deployment. You can implement progress bars, status reports, and all other things inside the handlers in this way.
Testing Link to heading
Testing is significant to validate everything that we have created. To do that, I created a CliTestBase
that enables me to test my logic against my CLI application.
This class inherits the ApplicationFactory
class to execute necessary steps on the way of accessing a proper CLI pipeline, and also creates a TestConsole
to track what is happening in logs. If the output is crucial, then you can even write a test against it.
If we come to test methods which are, in other words: Facts
:
Some checks ensure standard out is appropriately written, and ExitCode
is OK, so on. But I would also like to mention how we can replace a dependency inside the test. So, take a look at Can_Execute_MigrationStrategy_With_Something_From_Test_Context
method. It shows how a fake dependency gets replaced by the test context.
Source Code Link to heading
You can find the source code here