diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..355cdfe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +#### Define style #### + +# All files +[*] +indent_style = space + +# C# Project, JS and CSS files +[*.{csproj,js,ts,css,scss}] +indent_size = 2 + +#### Suppress warnings #### + +# C# files +[*.cs] + +# CA1056: Uri properties should not be strings +dotnet_diagnostic.CA1056.severity = none + +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = none # Don't need translated exceptions + +# CA1308: Normalize strings to uppercase +dotnet_diagnostic.CA1308.severity = none # Also to lower is required + +# CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = none # I like underscores into constants name + +# CA1812: Avoid uninstantiated internal classes +dotnet_diagnostic.CA1812.severity = none # Doing extensive use of Dependency Injection + +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = none # Don't like static members + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = none # .Net core doesn't require it + +# CA2234: Pass system uri objects instead of strings +dotnet_diagnostic.CA2234.severity = none diff --git a/.github/workflows/publish-stable.yml b/.github/workflows/publish-stable.yml new file mode 100644 index 0000000..0e512e6 --- /dev/null +++ b/.github/workflows/publish-stable.yml @@ -0,0 +1,150 @@ +name: Publish stable release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + release: + name: Release + + strategy: + matrix: + kind: [ + 'linux-x64', + 'linux-arm', + 'linux-arm64', + + 'linux-selfcont-x64', + 'linux-selfcont-arm', + 'linux-selfcont-arm64', + + 'macos-x64', + 'macos-arm64', + + 'macos-selfcont-x64', + 'macos-selfcont-arm64', + + 'windows-x86', + 'windows-x64', + 'windows-arm', + 'windows-arm64', + + 'windows-selfcont-x86', + 'windows-selfcont-x64', + 'windows-selfcont-arm', + 'windows-selfcont-arm64'] + include: + - kind: linux-x64 + os: ubuntu-latest + target: linux-x64 + frameworktype: --no-self-contained + - kind: linux-arm + os: ubuntu-latest + target: linux-arm + frameworktype: --no-self-contained + - kind: linux-arm64 + os: ubuntu-latest + target: linux-arm64 + frameworktype: --no-self-contained + + - kind: linux-selfcont-x64 + os: ubuntu-latest + target: linux-x64 + frameworktype: --self-contained + - kind: linux-selfcont-arm + os: ubuntu-latest + target: linux-arm + frameworktype: --self-contained + - kind: linux-selfcont-arm64 + os: ubuntu-latest + target: linux-arm64 + frameworktype: --self-contained + + - kind: macos-x64 + os: macOS-latest + target: osx-x64 + frameworktype: --no-self-contained + - kind: macos-arm64 + os: macOS-latest + target: osx-arm64 + frameworktype: --no-self-contained + + - kind: macos-selfcont-x64 + os: macOS-latest + target: osx-x64 + frameworktype: --self-contained + - kind: macos-selfcont-arm64 + os: macOS-latest + target: osx-arm64 + frameworktype: --self-contained + + - kind: windows-x86 + os: windows-latest + target: win-x86 + frameworktype: --no-self-contained + - kind: windows-x64 + os: windows-latest + target: win-x64 + frameworktype: --no-self-contained + - kind: windows-arm + os: windows-latest + target: win-arm + frameworktype: --no-self-contained + - kind: windows-arm64 + os: windows-latest + target: win-arm64 + frameworktype: --no-self-contained + + - kind: windows-selfcont-x86 + os: windows-latest + target: win-x86 + frameworktype: --self-contained + - kind: windows-selfcont-x64 + os: windows-latest + target: win-x64 + frameworktype: --self-contained + - kind: windows-selfcont-arm + os: windows-latest + target: win-arm + frameworktype: --self-contained + - kind: windows-selfcont-arm64 + os: windows-latest + target: win-arm64 + frameworktype: --self-contained + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Build EthernaGatewayCli project + shell: bash + run: | + tag=$(git describe --tags --abbrev=0) + release_name="etherna-gateway-cli-$tag-${{ matrix.kind }}" + # Build everything + dotnet publish src/EthernaGatewayCli/EthernaGatewayCli.csproj --runtime "${{ matrix.target }}" "${{ matrix.frameworktype }}" -c Release -o "$release_name" + # Pack files + if [ "${{ matrix.kind }}" == windows* ]; then + 7z a -tzip "${release_name}.zip" "./${release_name}/*" + else + tar czvf "${release_name}.tar.gz" "$release_name" + fi + + - name: Publish + uses: softprops/action-gh-release@v1 + with: + files: | + etherna-gateway-cli-* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/EthernaGatewayCli.sln b/EthernaGatewayCli.sln index 1660173..32dc09b 100644 --- a/EthernaGatewayCli.sln +++ b/EthernaGatewayCli.sln @@ -5,6 +5,9 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D3B7E18B-46F8-4072-9D14-801FDB15868C}" ProjectSection(SolutionItems) = preProject README.md = README.md + nuget.config = nuget.config + .gitignore = .gitignore + .editorconfig = .editorconfig EndProjectSection EndProject Global diff --git a/EthernaGatewayCli.sln.DotSettings b/EthernaGatewayCli.sln.DotSettings new file mode 100644 index 0000000..f73d392 --- /dev/null +++ b/EthernaGatewayCli.sln.DotSettings @@ -0,0 +1,17 @@ + + Copyright 2024-present Etherna SA + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + True + True + True \ No newline at end of file diff --git a/README.md b/README.md index e93b7de..107186b 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,36 @@ A CLI interface to the Etherna Gateway -## Issue reports +## Instructions +Download and extract binaries from [release page](https://github.com/Etherna/etherna-gateway-cli/releases). +Etherna Gateway CLI requires at least [.NET 8 Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) and [ASP.NET Core 8 Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) installed on local machine to run, or it needs the `selfcontained` version of package, that already contains framework dependencies. + +### How to use + +``` +etherna +A CLI interface to the Etherna Gateway + +Usage: etherna [OPTIONS] COMMAND + +Commands: + download Download a resource from Swarm + postage Manage postage batches + resource Manage Swarm resources + upload Upload a resource to Swarm + +Options: + -k, --api-key string Api Key (optional) + -i, --ignore-update Ignore new versions of EthernaGatewayCli + +Run 'etherna -h' or 'etherna --help' to print help. +Run 'etherna COMMAND -h' or 'etherna COMMAND --help' for more information on a command. +``` + +# Issue reports If you've discovered a bug, or have an idea for a new feature, please report it to our issue manager based on Jira https://etherna.atlassian.net/projects/EGC. -## Questions? Problems? +# Questions? Problems? For questions or problems please write an email to [info@etherna.io](mailto:info@etherna.io). diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..dd4edcc --- /dev/null +++ b/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/EthernaGatewayCli/Commands/Etherna/DownloadCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/DownloadCommand.cs new file mode 100644 index 0000000..7863c41 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/DownloadCommand.cs @@ -0,0 +1,54 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.BeeNet.Clients.GatewayApi; +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna +{ + public class DownloadCommand : CommandBase + { + // Fields. + private readonly IAuthenticationService authService; + private readonly IBeeGatewayClient beeGatewayClient; + + // Constructor. + public DownloadCommand( + IAuthenticationService authService, + IBeeGatewayClient beeGatewayClient, + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + this.authService = authService; + this.beeGatewayClient = beeGatewayClient; + } + + // Properties. + public override string CommandArgsHelpString => "RESOURCE"; + public override string Description => "Download a resource from Swarm"; + + // Protected methods. + protected override async Task ExecuteAsync(string[] commandArgs) + { + if (!Options.RunAnonymously) + await authService.SignInAsync(); + + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/DownloadCommandOptions.cs b/src/EthernaGatewayCli/Commands/Etherna/DownloadCommandOptions.cs new file mode 100644 index 0000000..e8a9a10 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/DownloadCommandOptions.cs @@ -0,0 +1,34 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using System; +using System.Collections.Generic; + +namespace Etherna.GatewayCli.Commands.Etherna +{ + public class DownloadCommandOptions : CommandOptionsBase + { + // Definitions. + public override IEnumerable Definitions => new CommandOption[] + { + new("-a", "--anon", "Download resource anonymously", _ => RunAnonymously = true), + new("-o", "--output", "Resource output path. Default: current directory", args => OutputPath = args[0], new[] { typeof(string) }) + }; + + // Options. + public string OutputPath { get; private set; } = Environment.CurrentDirectory; + public bool RunAnonymously { get; private set; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Postage/CreateCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/Postage/CreateCommand.cs new file mode 100644 index 0000000..1ca39ba --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Postage/CreateCommand.cs @@ -0,0 +1,66 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna.Postage +{ + public class CreateCommand : CommandBase + { + // Fields. + private readonly IAuthenticationService authService; + private readonly IGatewayService gatewayService; + + // Constructor. + public CreateCommand( + IAuthenticationService authService, + IGatewayService gatewayService, + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + this.authService = authService; + this.gatewayService = gatewayService; + } + + // Properties. + public override string Description => "Create a new postage batch"; + + // Methods. + protected override async Task ExecuteAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + + // Parse args. + if (commandArgs.Length != 0) + throw new ArgumentException("Create postage batch doesn't receive arguments"); + + // Authenticate user. + await authService.SignInAsync(); + + // Create postage. + var amount = Options.Amount ?? (Options.Ttl.HasValue + ? await gatewayService.CalculateAmountAsync(Options.Ttl.Value) + : throw new InvalidOperationException("Amount ot ttl are required")); + var batchId = await gatewayService.CreatePostageBatchAsync(amount, Options.Depth, Options.Label); + + // Print result. + IoService.WriteLine(); + IoService.WriteLine($"Postage batch id: {batchId}"); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Postage/CreateCommandOptions.cs b/src/EthernaGatewayCli/Commands/Etherna/Postage/CreateCommandOptions.cs new file mode 100644 index 0000000..5135dc5 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Postage/CreateCommandOptions.cs @@ -0,0 +1,47 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Models.Commands.OptionRequirements; +using System; +using System.Collections.Generic; + +namespace Etherna.GatewayCli.Commands.Etherna.Postage +{ + public class CreateCommandOptions : CommandOptionsBase + { + // Definitions. + public override IEnumerable Definitions => new CommandOption[] + { + new("-a", "--amount", "Specify the amount to use", args => Amount = long.Parse(args[0]), [typeof(long)]), + new("-d", "--depth", "Specify the postage batch depth", args => Depth = int.Parse(args[0]), [typeof(int)]), + new("-l", "--label", "Set a custom postage batch label", args => Label = args[0], [typeof(string)]), + new("-t", "--ttl", "Specify the time to live to obtain in days", args => Ttl = TimeSpan.FromDays(int.Parse(args[0])), [typeof(int)]) + }; + public override IEnumerable Requirements => new OptionRequirementBase[] + { + new ExclusiveOptionRequirement("--amount", "--ttl"), + new RequireOneOfOptionRequirement("--amount", "--ttl"), + new RequireOneOfOptionRequirement("--depth"), + new MinValueOptionRequirement("--depth", 17), + new MinValueOptionRequirement("--ttl", 1) + }; + + // Options. + public long? Amount { get; private set; } + public int Depth { get; private set; } + public string? Label { get; private set; } + public TimeSpan? Ttl { get; private set; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Postage/InfoCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/Postage/InfoCommand.cs new file mode 100644 index 0000000..b3a76a6 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Postage/InfoCommand.cs @@ -0,0 +1,77 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using Etherna.Sdk.GeneratedClients.Gateway; +using System; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna.Postage +{ + public class InfoCommand : CommandBase + { + // Consts. + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; + + // Fields. + private readonly IAuthenticationService authService; + private readonly IGatewayService gatewayService; + + // Constructor. + public InfoCommand( + IAuthenticationService authService, + IGatewayService gatewayService, + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + this.authService = authService; + this.gatewayService = gatewayService; + } + + // Properties. + public override string Description => "Get info about a postage batch"; + public override string CommandArgsHelpString => "POSTAGE_ID"; + + // Methods. + protected override async Task ExecuteAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + + // Parse args. + if (commandArgs.Length != 1) + throw new ArgumentException("Get postage batch info requires exactly 1 argument"); + + // Authenticate user. + await authService.SignInAsync(); + + // Get postage info + PostageBatchDto? postageBatch = null; + try + { + postageBatch = await gatewayService.GetPostageBatchInfoAsync(commandArgs[0]); + } + catch (EthernaGatewayApiException e) when (e.StatusCode == 404) + { } + + // Print result. + IoService.WriteLine(); + IoService.WriteLine(postageBatch is null + ? "Postage batch not found." + : JsonSerializer.Serialize(postageBatch, SerializerOptions)); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/PostageCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/PostageCommand.cs new file mode 100644 index 0000000..2ec645a --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/PostageCommand.cs @@ -0,0 +1,33 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; + +namespace Etherna.GatewayCli.Commands.Etherna +{ + public class PostageCommand : CommandBase + { + // Constructor. + public PostageCommand( + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { } + + // Properties. + public override string Description => "Manage postage batches"; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Resource/DefundCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/Resource/DefundCommand.cs new file mode 100644 index 0000000..a0b5b59 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Resource/DefundCommand.cs @@ -0,0 +1,39 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna.Resource +{ + public class DefundCommand : CommandBase + { + public DefundCommand( + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + } + + public override string CommandArgsHelpString => "RESOURCE_ID"; + public override string Description => "Defund resource budget"; + + protected override Task ExecuteAsync(string[] commandArgs) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Resource/DefundCommandOptions.cs b/src/EthernaGatewayCli/Commands/Etherna/Resource/DefundCommandOptions.cs new file mode 100644 index 0000000..6f46f5f --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Resource/DefundCommandOptions.cs @@ -0,0 +1,37 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Models.Commands.OptionRequirements; +using System.Collections.Generic; + +namespace Etherna.GatewayCli.Commands.Etherna.Resource +{ + public class DefundCommandOptions : CommandOptionsBase + { + public override IEnumerable Definitions => new[] + { + new CommandOption("-p", "--pin", "Defund resource pinning on gateway", _ => DefundPinning = true), + new CommandOption("-t", "--traffic", "Defund resource traffic to everyone", _ => DefundTraffic = true) + }; + + public override IEnumerable Requirements => new[] + { + new RequireOneOfOptionRequirement("-p", "-t") + }; + + public bool DefundPinning { get; set; } + public bool DefundTraffic { get; private set; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Resource/FundCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/Resource/FundCommand.cs new file mode 100644 index 0000000..4ab519e --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Resource/FundCommand.cs @@ -0,0 +1,92 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna.Resource +{ + public class FundCommand : CommandBase + { + // Fields. + private readonly IAuthenticationService authService; + private readonly IGatewayService gatewayService; + + // Constructor. + public FundCommand( + IAuthenticationService authService, + IGatewayService gatewayService, + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + this.authService = authService; + this.gatewayService = gatewayService; + } + + // Properties. + public override string CommandArgsHelpString => "RESOURCE_ID"; + public override string Description => "Fund resource budget"; + + // Methods. + protected override async Task ExecuteAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + + // Parse args. + if (commandArgs.Length != 1) + throw new ArgumentException("Fund resource require exactly 1 argument"); + var resourceHash = commandArgs[0]; + + // Authenticate user. + await authService.SignInAsync(); + + // Fund resource. + IoService.WriteLine($"Funding resource {resourceHash}..."); + if (Options.FundPinning) + { +#pragma warning disable CA1031 + try + { + await gatewayService.FundResourcePinningAsync(resourceHash); + IoService.WriteLine($"Resource pinning funded"); + } + catch (Exception e) + { + IoService.WriteErrorLine($"Error funding resource pinning"); + IoService.WriteLine(e.ToString()); + } +#pragma warning restore CA1031 + } + + if (Options.FundTraffic) + { +#pragma warning disable CA1031 + try + { + await gatewayService.FundResourceTrafficAsync(resourceHash); + IoService.WriteLine($"Resource traffic funded"); + } + catch (Exception e) + { + IoService.WriteErrorLine($"Error funding resource traffic"); + IoService.WriteLine(e.ToString()); + } +#pragma warning restore CA1031 + } + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Resource/FundCommandOptions.cs b/src/EthernaGatewayCli/Commands/Etherna/Resource/FundCommandOptions.cs new file mode 100644 index 0000000..8a21402 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Resource/FundCommandOptions.cs @@ -0,0 +1,37 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Models.Commands.OptionRequirements; +using System.Collections.Generic; + +namespace Etherna.GatewayCli.Commands.Etherna.Resource +{ + public class FundCommandOptions : CommandOptionsBase + { + public override IEnumerable Definitions => new[] + { + new CommandOption("-p", "--pin", "Fund resource pinning on gateway", _ => FundPinning = true), + new CommandOption("-t", "--traffic", "Fund resource traffic to everyone", _ => FundTraffic = true) + }; + + public override IEnumerable Requirements => new[] + { + new RequireOneOfOptionRequirement("-p", "-t") + }; + + public bool FundPinning { get; set; } + public bool FundTraffic { get; private set; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/Resource/ListCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/Resource/ListCommand.cs new file mode 100644 index 0000000..6ee1f44 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/Resource/ListCommand.cs @@ -0,0 +1,38 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna.Resource +{ + public class ListCommand : CommandBase + { + public ListCommand( + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + } + + public override string Description => "List resources"; + + protected override Task ExecuteAsync(string[] commandArgs) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/ResourceCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/ResourceCommand.cs new file mode 100644 index 0000000..3e8a84b --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/ResourceCommand.cs @@ -0,0 +1,33 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using System; + +namespace Etherna.GatewayCli.Commands.Etherna +{ + public class ResourceCommand : CommandBase + { + // Constructor. + public ResourceCommand( + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { } + + // Properties. + public override string Description => "Manage Swarm resources"; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/UploadCommand.cs b/src/EthernaGatewayCli/Commands/Etherna/UploadCommand.cs new file mode 100644 index 0000000..6bd7a5d --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/UploadCommand.cs @@ -0,0 +1,215 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.BeeNet.InputModels; +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using Etherna.Sdk.GeneratedClients.Gateway; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands.Etherna +{ + public class UploadCommand : CommandBase + { + // Consts. + private const int UploadMaxRetry = 10; + private readonly TimeSpan UploadRetryTimeSpan = TimeSpan.FromSeconds(5); + + // Fields. + private readonly IAuthenticationService authService; + private readonly IFileService fileService; + private readonly IGatewayService gatewayService; + + // Constructor. + public UploadCommand( + IAuthenticationService authService, + IFileService fileService, + IGatewayService gatewayService, + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { + this.authService = authService; + this.fileService = fileService; + this.gatewayService = gatewayService; + } + + // Properties. + public override string CommandArgsHelpString => "SOURCE_FILE [SOURCE_FILE ...]"; + public override string Description => "Upload a file resource to Swarm"; + + // Methods. + protected override async Task ExecuteAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + + // Parse args. + if (commandArgs.Length < 1) + throw new ArgumentException("Upload requires 1 or more arguments"); + var filePaths = commandArgs; + + // Search files and calculate total file size. + var contentByteSize = 0L; + foreach (var filePath in filePaths) + { + if (!File.Exists(filePath)) + throw new InvalidOperationException($"File {filePath} doesn't exist"); + + var fileInfo = new FileInfo(filePath); + contentByteSize += fileInfo.Length; + } + + // Authenticate user. + await authService.SignInAsync(); + + // Identify postage batch to use. + var postageBatchId = await GetUsablePostageBatchIdAsync(contentByteSize); + + // Upload file. + foreach (var filePath in filePaths) + { + IoService.WriteLine($"Uploading {filePath}..."); + + var uploadSucceeded = false; + string refHash = default!; + for (int i = 0; i < UploadMaxRetry && !uploadSucceeded; i++) + { + try + { + await using var fileStream = File.Open(filePath, FileMode.Open); + var mimeType = fileService.GetMimeType(filePath); + + refHash = await gatewayService.UploadFileAsync( + postageBatchId, + fileStream, + Path.GetFileName(filePath), + mimeType, + Options.PinResource); + IoService.WriteLine($"Ref hash: {refHash}"); + + uploadSucceeded = true; + } +#pragma warning disable CA1031 + catch (Exception e) + { + IoService.WriteErrorLine($"Error uploading {filePath}"); + IoService.WriteLine(e.ToString()); + + if (i + 1 < UploadMaxRetry) + { + Console.WriteLine("Retry..."); + await Task.Delay(UploadRetryTimeSpan); + } + } +#pragma warning restore CA1031 + } + + if (!uploadSucceeded) + IoService.WriteErrorLine($"Can't upload \"{filePath}\" after {UploadMaxRetry} retries"); + else if (Options.OfferDownload) + { +#pragma warning disable CA1031 + try + { + await gatewayService.FundResourceTrafficAsync(refHash); + IoService.WriteLine($"Resource traffic funded"); + } + catch (Exception e) + { + IoService.WriteErrorLine($"Error funding resource traffic"); + IoService.WriteLine(e.ToString()); + } +#pragma warning restore CA1031 + } + } + } + + // Helpers. + private async Task GetUsablePostageBatchIdAsync(long contentByteSize) + { + if (Options.UsePostageBatchId is null) + { + //create a new postage batch + var batchDepth = gatewayService.CalculateDepth(contentByteSize); + var amount = await gatewayService.CalculateAmountAsync(Options.NewPostageTtl); + var bzzPrice = gatewayService.CalculateBzzPrice(amount, batchDepth); + + IoService.WriteLine($"Required postage batch Depth: {batchDepth}, Amount: {amount}, BZZ price: {bzzPrice}"); + + if (!Options.NewPostageAutoPurchase) + { + bool validSelection = false; + + while (validSelection == false) + { + IoService.WriteLine($"Confirm the batch purchase? Y to confirm, N to deny [Y|n]"); + + switch (IoService.ReadKey()) + { + case { Key: ConsoleKey.Y }: + case { Key: ConsoleKey.Enter }: + validSelection = true; + break; + case { Key: ConsoleKey.N }: + throw new InvalidOperationException("Batch purchase denied"); + default: + IoService.WriteLine("Invalid selection"); + break; + } + } + } + + //create batch + var postageBatchId = await gatewayService.CreatePostageBatchAsync(amount, batchDepth, Options.NewPostageLabel); + + IoService.WriteLine($"Created postage batch: {postageBatchId}"); + + return postageBatchId; + } + else + { + //get info about existing postage batch + PostageBatchDto postageBatch; + try + { + postageBatch = await gatewayService.GetPostageBatchInfoAsync(Options.UsePostageBatchId); + } + catch (EthernaGatewayApiException e) when (e.StatusCode == 404) + { + IoService.WriteErrorLine($"Unable to find postage batch \"{Options.UsePostageBatchId}\"."); + throw; + } + + //verify if it is usable + if (!postageBatch.Usable) + { + IoService.WriteErrorLine($"Postage batch \"{Options.UsePostageBatchId}\" is not usable."); + throw new InvalidOperationException(); + } + + //verify if it has available space + if (gatewayService.CalculatePostageBatchByteSize(postageBatch) - + gatewayService.CalculateRequiredPostageBatchSpace(contentByteSize) < 0) + { + IoService.WriteErrorLine($"Postage batch \"{Options.UsePostageBatchId}\" has not enough space."); + throw new InvalidOperationException(); + } + + return postageBatch.Id; + } + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/Etherna/UploadCommandOptions.cs b/src/EthernaGatewayCli/Commands/Etherna/UploadCommandOptions.cs new file mode 100644 index 0000000..755ff78 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/Etherna/UploadCommandOptions.cs @@ -0,0 +1,50 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Models.Commands.OptionRequirements; +using System; +using System.Collections.Generic; + +namespace Etherna.GatewayCli.Commands.Etherna +{ + public class UploadCommandOptions : CommandOptionsBase + { + // Consts. + private static readonly TimeSpan DefaultPostageBatchTtl = TimeSpan.FromDays(365); + + // Definitions. + public override IEnumerable Definitions => new CommandOption[] + { + new(null, "--postage", "Use an existing postage batch. Create a new otherwise", args => UsePostageBatchId = args[0], [typeof(string)]), + new("-A", "--auto-purchase", "Auto purchase new postage batch", _ => NewPostageAutoPurchase = true), + new("-l", "--label", "Label of new postage batch", args => NewPostageLabel = args[0], [typeof(string)]), + new("-t", "--ttl", $"TTL (days) of new postage batch (default: {DefaultPostageBatchTtl.Days} days)", args => NewPostageTtl = TimeSpan.FromDays(int.Parse(args[0])), [typeof(int)]), + new("-f", "--fund-traffic", "Fund resource traffic to everyone", _ => OfferDownload = true), + new(null, "--no-pin", "Don't pin resource (pinning enabled by default)", _ => PinResource = false) + }; + public override IEnumerable Requirements => new OptionRequirementBase[] + { + new IfPresentThenOptionRequirement("--postage", new ForbiddenOptionRequirement("--auto-purchase", "--label", "--ttl")) + }; + + // Options. + public bool OfferDownload { get; private set; } + public bool NewPostageAutoPurchase { get; private set; } + public string? NewPostageLabel { get; private set; } + public TimeSpan NewPostageTtl { get; private set; } = DefaultPostageBatchTtl; + public bool PinResource { get; private set; } = true; + public string? UsePostageBatchId { get; private set; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/EthernaCommand.cs b/src/EthernaGatewayCli/Commands/EthernaCommand.cs new file mode 100644 index 0000000..1b10f88 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/EthernaCommand.cs @@ -0,0 +1,49 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using Etherna.GatewayCli.Utilities; +using System; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Commands +{ + public class EthernaCommand : CommandBase + { + // Constructor. + public EthernaCommand( + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { } + + // Properties. + public override string Description => "A CLI interface to the Etherna Gateway"; + public override bool IsRootCommand => true; + + // Protected methods. + protected override async Task ExecuteAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + + // Check for new versions. + var newVersionAvailable = await EthernaVersionControl.CheckNewVersionAsync(IoService); + if (newVersionAvailable && !Options.IgnoreUpdate) + return; + + await ExecuteSubCommandAsync(commandArgs); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Commands/EthernaCommandOptions.cs b/src/EthernaGatewayCli/Commands/EthernaCommandOptions.cs new file mode 100644 index 0000000..98b8315 --- /dev/null +++ b/src/EthernaGatewayCli/Commands/EthernaCommandOptions.cs @@ -0,0 +1,34 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands; +using System; +using System.Collections.Generic; + +namespace Etherna.GatewayCli.Commands +{ + public class EthernaCommandOptions : CommandOptionsBase + { + // Definitions. + public override IEnumerable Definitions => new CommandOption[] + { + new("-k", "--api-key", "Api Key (optional)", args => ApiKey = args[0], new[] { typeof(string) }), + new("-i", "--ignore-update", "Ignore new versions of EthernaGatewayCli", _ => IgnoreUpdate = true) + }; + + // Options. + public string? ApiKey { get; private set; } + public bool IgnoreUpdate{ get; private set; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/CommonConsts.cs b/src/EthernaGatewayCli/CommonConsts.cs new file mode 100644 index 0000000..91b1324 --- /dev/null +++ b/src/EthernaGatewayCli/CommonConsts.cs @@ -0,0 +1,28 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.BeeNet.Clients.GatewayApi; +using System; + +namespace Etherna.GatewayCli +{ + public static class CommonConsts + { + public const string EthernaGatewayCliClientId = "ethernaGatewayCliId"; + public const string EthernaGatewayUrl = "https://gateway.etherna.io/"; + public const string EthernaSsoUrl = "https://sso.etherna.io/"; + public static readonly TimeSpan GnosisBlockTime = TimeSpan.FromSeconds(5); + public const string HttpClientName = "ethernaAuthnHttpClient"; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/EthernaGatewayCli.csproj b/src/EthernaGatewayCli/EthernaGatewayCli.csproj index 2f4fc77..0834a06 100644 --- a/src/EthernaGatewayCli/EthernaGatewayCli.csproj +++ b/src/EthernaGatewayCli/EthernaGatewayCli.csproj @@ -1,10 +1,47 @@  - - Exe - net8.0 - enable - enable - + + Exe + net8.0 + Etherna.GatewayCli + + Etherna SA + A CLI interface to the Etherna Gateway + + true + enable + true + AllEnabledByDefault + + true + + etherna + https://github.com/Etherna/etherna-gateway-cli + git + true + true + snupkg + LICENSE + true + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/EthernaGatewayCli/Models/Commands/CommandBase.cs b/src/EthernaGatewayCli/Models/Commands/CommandBase.cs new file mode 100644 index 0000000..1d2c487 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/CommandBase.cs @@ -0,0 +1,319 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Services; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Models.Commands +{ + public abstract class CommandBase + { + // Fields. + private readonly IServiceProvider serviceProvider; + private ImmutableArray? _availableSubCommandTypes; + private ImmutableArray? _commandPathTypes; + + // Constructor. + protected CommandBase( + IIoService ioService, + IServiceProvider serviceProvider) + { + IoService = ioService; + this.serviceProvider = serviceProvider; + } + + // Properties. + public ImmutableArray AvailableSubCommandTypes + { + get + { + if (_availableSubCommandTypes is null) + { + var subCommandsNamespace = GetType().Namespace + "." + GetType().Name.Replace("Command", ""); + _availableSubCommandTypes = typeof(Program).GetTypeInfo().Assembly.GetTypes() + .Where(t => t is {IsClass:true, IsAbstract: false} && + t.Namespace == subCommandsNamespace && + typeof(CommandBase).IsAssignableFrom(t)) + .OrderBy(t => t.Name) + .ToImmutableArray(); + } + return _availableSubCommandTypes.Value; + } + } + public string CommandPathNames => string.Join(' ', + CommandPathTypes.Select(cType => ((CommandBase)serviceProvider.GetRequiredService(cType)).Name)); + public ImmutableArray CommandPathTypes + { + get + { + if (_commandPathTypes is null) + { + var currentCommandNamespace = GetType().Namespace; + if (currentCommandNamespace is null) + throw new InvalidOperationException(); + + _commandPathTypes = GetParentCommandTypesFromNamespace(currentCommandNamespace) + .Append(GetType()).ToImmutableArray(); + } + return _commandPathTypes.Value; + } + } + public virtual string CommandArgsHelpString => HasSubCommands ? "COMMAND" : ""; + public string CommandPathUsageHelpString + { + get + { + var strBuilder = new StringBuilder(); + foreach (var commandType in CommandPathTypes) + { + var command = (CommandBase)serviceProvider.GetRequiredService(commandType); + strBuilder.Append(command.Name); + if (command.HasOptions) + { + strBuilder.Append(command.HasRequiredOptions ? + $" {command.Name.ToUpperInvariant()}_OPTIONS" : + $" [{command.Name.ToUpperInvariant()}_OPTIONS]"); + } + + strBuilder.Append(' '); + } + strBuilder.Append(CommandArgsHelpString); + return strBuilder.ToString(); + } + } + public abstract string Description { get; } + public virtual bool HasOptions => false; + public virtual bool HasRequiredOptions => false; + public bool HasSubCommands => AvailableSubCommandTypes.Any(); + public virtual bool IsRootCommand => false; + public string Name => GetCommandNameFromType(GetType()); + public virtual bool PrintHelpWithNoArgs => true; + + // Protected properties. + protected IIoService IoService { get; } + + // Public methods. + public async Task RunAsync(string[] args) + { + // Parse arguments. + var printHelp = EvaluatePrintHelp(args); + var optionArgsCount = printHelp ? 0 : ParseOptionArgs(args); + + // Print help or run command. + if (printHelp) + PrintHelp(); + else + await ExecuteAsync(args[optionArgsCount..]); + } + + // Protected methods. + protected virtual void AppendOptionsHelp(StringBuilder strBuilder) { } + + /// + /// Parse command options + /// + /// Input args + /// Found option args counter + protected virtual int ParseOptionArgs(string[] args) => 0; + + protected virtual async Task ExecuteAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + await ExecuteSubCommandAsync(commandArgs); + } + + protected async Task ExecuteSubCommandAsync(string[] commandArgs) + { + ArgumentNullException.ThrowIfNull(commandArgs, nameof(commandArgs)); + + if (commandArgs.Length == 0) + throw new ArgumentException("A command name is required"); + + var subCommandName = commandArgs[0]; + var subCommandArgs = commandArgs[1..]; + + var selectedCommandType = AvailableSubCommandTypes.FirstOrDefault( + t => GetCommandNameFromType(t) == subCommandName); + + if (selectedCommandType is null) + throw new ArgumentException($"{CommandPathNames}: '{subCommandName}' is not a valid command."); + + var selectedCommand = (CommandBase)serviceProvider.GetRequiredService(selectedCommandType); + await selectedCommand.RunAsync(subCommandArgs); + } + + // Protected helpers. + protected static string GetCommandNameFromType(Type commandType) + { + ArgumentNullException.ThrowIfNull(commandType, nameof(commandType)); + + if (!typeof(CommandBase).IsAssignableFrom(commandType)) + throw new ArgumentException($"{commandType.Name} is not a command type"); + + return commandType.Name.Replace("Command", "").ToLowerInvariant(); + } + + // Private helpers. + private bool EvaluatePrintHelp(string[] args) + { + ArgumentNullException.ThrowIfNull(args, nameof(args)); + + switch (args.Length) + { + case 0 when PrintHelpWithNoArgs: + return true; + case 1: + switch (args[0]) + { + case "-h": + case "--help": + return true; + } + break; + } + return false; + } + + private static IEnumerable GetParentCommandTypesFromNamespace(string currentNamespace) + { + var lastSeparatorIndex = currentNamespace.LastIndexOf('.'); + var parentNamespace = currentNamespace[..lastSeparatorIndex]; + var parentCommandName = currentNamespace[(lastSeparatorIndex + 1)..] + "Command"; + var parentCommandType = typeof(CommandBase).GetTypeInfo().Assembly.GetTypes() + .FirstOrDefault(t => t is { IsClass: true, IsAbstract: false } && + t.FullName == parentNamespace + '.' + parentCommandName && + typeof(CommandBase).IsAssignableFrom(t)); + + if (parentCommandType is null) + return Array.Empty(); + return GetParentCommandTypesFromNamespace(parentNamespace).Append(parentCommandType); + } + + [SuppressMessage("Performance", "CA1851:Possible multiple enumerations of \'IEnumerable\' collection")] + private void PrintHelp() + { + var strBuilder = new StringBuilder(); + + // Add name and description. + strBuilder.AppendLine(CommandPathNames); + strBuilder.AppendLine(Description); + strBuilder.AppendLine(); + + // Add usage. + strBuilder.AppendLine($"Usage: {CommandPathUsageHelpString}"); + strBuilder.AppendLine(); + + // Add sub commands. + var availableSubCommandTypes = AvailableSubCommandTypes; + if (availableSubCommandTypes.Any()) + { + var allSubCommands = availableSubCommandTypes.Select(t => (CommandBase)serviceProvider.GetRequiredService(t)); + + strBuilder.AppendLine("Commands:"); + var descriptionShift = allSubCommands.Select(c => c.Name.Length).Max() + 4; + foreach (var command in allSubCommands) + { + strBuilder.Append(" "); + strBuilder.Append(command.Name); + for (int i = 0; i < descriptionShift - command.Name.Length; i++) + strBuilder.Append(' '); + strBuilder.AppendLine(command.Description); + } + strBuilder.AppendLine(); + } + + // Add options. + AppendOptionsHelp(strBuilder); + + // Add print help. + strBuilder.AppendLine($"Run '{CommandPathNames} -h' or '{CommandPathNames} --help' to print help."); + if (IsRootCommand) + strBuilder.AppendLine($"Run '{CommandPathNames} COMMAND -h' or '{CommandPathNames} COMMAND --help' for more information on a command."); + strBuilder.AppendLine(); + + // Print it. + var helpOutput = strBuilder.ToString(); + IoService.Write(helpOutput); + } + } + + public abstract class CommandBase : CommandBase + where TOptions: CommandOptionsBase, new() + { + // Constructor. + protected CommandBase( + IIoService ioService, + IServiceProvider serviceProvider) + : base(ioService, serviceProvider) + { } + + // Properties. + public override bool HasOptions => true; + public override bool HasRequiredOptions => Options.AreRequired; + public TOptions Options { get; } = new TOptions(); + + // Methods. + protected override int ParseOptionArgs(string[] args) => Options.ParseArgs(args, IoService); + + protected override void AppendOptionsHelp(StringBuilder strBuilder) + { + ArgumentNullException.ThrowIfNull(strBuilder, nameof(strBuilder)); + + if (!Options.Definitions.Any()) return; + + // Option descriptions. + strBuilder.AppendLine("Options:"); + var descriptionShift = Options.Definitions.Select(opt => + { + var len = opt.LongName.Length; + foreach (var reqArgType in opt.RequiredArgTypes) + len += reqArgType.Name.Length + 1; + return len; + }).Max() + 4; + foreach (var option in Options.Definitions) + { + strBuilder.Append(" "); + strBuilder.Append(option.ShortName is null ? " " : $"{option.ShortName}, "); + strBuilder.Append(option.LongName); + var strLen = option.LongName.Length; + foreach (var reqArgType in option.RequiredArgTypes) + { + strBuilder.Append($" {reqArgType.Name.ToLower()}"); + strLen += reqArgType.Name.Length + 1; + } + for (int i = 0; i < descriptionShift - strLen; i++) + strBuilder.Append(' '); + strBuilder.AppendLine(option.Description); + } + strBuilder.AppendLine(); + + // Requirements. + if (Options.Requirements.Any()) + { + strBuilder.AppendLine("Option requirements:"); + foreach (var requirement in Options.Requirements) + strBuilder.AppendLine(" " + requirement.PrintHelpLine(Options)); + strBuilder.AppendLine(); + } + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/CommandOption.cs b/src/EthernaGatewayCli/Models/Commands/CommandOption.cs new file mode 100644 index 0000000..a00a3fb --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/CommandOption.cs @@ -0,0 +1,55 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Etherna.GatewayCli.Models.Commands +{ + public class CommandOption + { + // Regex to validate option names. + private static readonly Regex shortNameRegex = new("^-[A-Za-z0-9]$"); + private static readonly Regex longNameRegex = new("^--[A-Za-z0-9-]+$"); + + // Constructor. + public CommandOption( + string? shortName, + string longName, + string description, + Action onFound, + IEnumerable? requiredArgTypes = null) + { + if (shortName != null && !shortNameRegex.IsMatch(shortName)) + throw new ArgumentException("Invalid short option name"); + + if (!longNameRegex.IsMatch(longName)) + throw new ArgumentException("Invalid long option name"); + + ShortName = shortName; + LongName = longName; + RequiredArgTypes = requiredArgTypes ?? Array.Empty(); + Description = description; + OnFound = onFound; + } + + // Properties. + public string Description { get; } + public Action OnFound { get; } + public string LongName { get; } + public IEnumerable RequiredArgTypes { get; } + public string? ShortName { get; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/CommandOptionsBase.cs b/src/EthernaGatewayCli/Models/Commands/CommandOptionsBase.cs new file mode 100644 index 0000000..55cf79d --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/CommandOptionsBase.cs @@ -0,0 +1,93 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Commands.OptionRequirements; +using Etherna.GatewayCli.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Etherna.GatewayCli.Models.Commands +{ + public abstract class CommandOptionsBase + { + // Properties. + public bool AreRequired => Requirements.OfType().Any(); + public abstract IEnumerable Definitions { get; } + public virtual IEnumerable Requirements => Array.Empty(); + + // Methods. + public CommandOption FindOptionByName(string name) => + Definitions.First(o => o.ShortName == name || o.LongName == name); + + /// + /// Parse command options + /// + /// Input args + /// Found option args counter + public virtual int ParseArgs( + string[] args, + IIoService ioService) + { + ArgumentNullException.ThrowIfNull(args, nameof(args)); + ArgumentNullException.ThrowIfNull(ioService, nameof(args)); + + // Parse options. + var parsedArgsCount = 0; + var foundOptions = new List(); + while (parsedArgsCount < args.Length && args[parsedArgsCount].StartsWith('-')) + { + var optName = args[parsedArgsCount++]; + + // Find option by name. + var foundOption = Definitions.FirstOrDefault(opt => opt.ShortName == optName || opt.LongName == optName); + if (foundOption is null) + throw new ArgumentException(optName + " is not a valid option"); + + // Verify duplicate options. + if (foundOptions.Any(opt => opt.Option.ShortName == optName || opt.Option.LongName == optName)) + throw new ArgumentException(optName + " option is duplicate"); + + // Check required option args. + if (args.Length - parsedArgsCount < foundOption.RequiredArgTypes.Count()) + throw new ArgumentException($"{optName} requires {foundOption.RequiredArgTypes.Count()} args: {string.Join(" ", foundOption.RequiredArgTypes.Select(t => t.Name.ToLower()))}"); + + // Exec option code. + var requiredOptArgs = args[parsedArgsCount..(parsedArgsCount + foundOption.RequiredArgTypes.Count())]; + parsedArgsCount += requiredOptArgs.Length; + foundOption.OnFound(requiredOptArgs); + + // Save on found options. + foundOptions.Add(new(foundOption, optName, requiredOptArgs)); + } + + // Verify option requirements. + var optionErrors = Requirements.SelectMany(r => r.ValidateOptions(this, foundOptions)).ToArray(); + if (optionErrors.Length != 0) + { + var errorStrBuilder = new StringBuilder(); + errorStrBuilder.AppendLine("Invalid options:"); + foreach (var error in optionErrors) + errorStrBuilder.AppendLine(" " + error.Message); + + ioService.WriteError(errorStrBuilder.ToString()); + + throw new ArgumentException("Errors with command options"); + } + + return parsedArgsCount; + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/ExclusiveOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/ExclusiveOptionRequirement.cs new file mode 100644 index 0000000..f4de0a9 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/ExclusiveOptionRequirement.cs @@ -0,0 +1,49 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class ExclusiveOptionRequirement(params string[] optionsNames) + : OptionRequirementBase(optionsNames) + { + // Methods. + public override string PrintHelpLine(CommandOptionsBase commandOptions) => + ComposeSentence(OptionsNames.Select(n => commandOptions.FindOptionByName(n).LongName)); + + public override IEnumerable ValidateOptions( + CommandOptionsBase commandOptions, + IEnumerable parsedOptions) + { + if (OptionsNames.Count(optName => TryFindParsedOption(parsedOptions, optName, out _)) >= 2) + { + var invalidParsedNames = parsedOptions.Where(parsedOpt => + OptionsNames.Contains(parsedOpt.Option.ShortName) || + OptionsNames.Contains(parsedOpt.Option.LongName)) + .Select(foundOpt => foundOpt.ParsedName); + + return [new OptionRequirementError(ComposeSentence(invalidParsedNames))]; + } + + return Array.Empty(); + } + + // Private helpers. + private string ComposeSentence(IEnumerable optNames) => + $"{string.Join(", ", optNames)} are mutual exclusive."; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/ForbiddenOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/ForbiddenOptionRequirement.cs new file mode 100644 index 0000000..9067b25 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/ForbiddenOptionRequirement.cs @@ -0,0 +1,48 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class ForbiddenOptionRequirement(params string[] optionsNames) + : OptionRequirementBase(optionsNames) + { + // Methods. + public override string PrintHelpLine(CommandOptionsBase commandOptions) => + string.Join(", ", OptionsNames.Select(n => commandOptions.FindOptionByName(n).LongName)) + + (OptionsNames.Count == 1 ? " is forbidden." : " are forbidden."); + + public override IEnumerable ValidateOptions(CommandOptionsBase commandOptions, IEnumerable parsedOptions) + { + if (OptionsNames.Any(optName => TryFindParsedOption(parsedOptions, optName, out _))) + { + var invalidParsedNames = parsedOptions.Where(parsedOpt => + OptionsNames.Contains(parsedOpt.Option.ShortName) || + OptionsNames.Contains(parsedOpt.Option.LongName)) + .Select(foundOpt => foundOpt.ParsedName); + + return [new OptionRequirementError(ComposeSentence(invalidParsedNames))]; + } + + return Array.Empty(); + } + + // Private helpers. + private string ComposeSentence(IEnumerable optNames) => + string.Join(", ", optNames) + (optNames.Count() == 1 ? " is forbidden." : " are forbidden."); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/IfPresentThenOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/IfPresentThenOptionRequirement.cs new file mode 100644 index 0000000..2fa9637 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/IfPresentThenOptionRequirement.cs @@ -0,0 +1,54 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class IfPresentThenOptionRequirement( + string optionsName, + OptionRequirementBase thenRequirement) + : OptionRequirementBase([optionsName]) + { + // Methods. + public override string PrintHelpLine(CommandOptionsBase commandOptions) + { + ArgumentNullException.ThrowIfNull(commandOptions, nameof(commandOptions)); + + return ComposeSentence( + commandOptions.FindOptionByName(OptionsNames.First()).LongName, + thenRequirement.PrintHelpLine(commandOptions), + commandOptions); + } + + public override IEnumerable ValidateOptions(CommandOptionsBase commandOptions, IEnumerable parsedOptions) + { + var optName = OptionsNames.First(); + + if (!TryFindParsedOption(parsedOptions, optName, out var parsedOption)) + return Array.Empty(); + + var thenErrors = thenRequirement.ValidateOptions(commandOptions, parsedOptions); + + return thenErrors.Select(thenError => + new OptionRequirementError(ComposeSentence(optName, thenError.Message, commandOptions))); + } + + // Private helpers. + private string ComposeSentence(string optName, string thenMessageLine, CommandOptionsBase commandOptions) => + $"If {optName} is present then {thenMessageLine}"; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/MaxValueOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/MaxValueOptionRequirement.cs new file mode 100644 index 0000000..2e60c5f --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/MaxValueOptionRequirement.cs @@ -0,0 +1,55 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class MaxValueOptionRequirement( + string optionsName, + double maxValue) + : OptionRequirementBase([optionsName]) + { + // Methods. + public override string PrintHelpLine(CommandOptionsBase commandOptions) + { + ArgumentNullException.ThrowIfNull(commandOptions, nameof(commandOptions)); + + return ComposeSentence(commandOptions.FindOptionByName(OptionsNames.First()).LongName); + } + + public override IEnumerable ValidateOptions( + CommandOptionsBase commandOptions, + IEnumerable parsedOptions) + { + var optName = OptionsNames.First(); + + if (!TryFindParsedOption(parsedOptions, optName, out var parsedOption)) + return Array.Empty(); + + if (!double.TryParse(parsedOption!.ParsedArgs.First(), out var doubleArg)) + return [new OptionRequirementError( + $"Invalid argument value: {parsedOption.ParsedName} {parsedOption.ParsedArgs.First()}")]; + + return doubleArg <= maxValue + ? Array.Empty() + : [new OptionRequirementError(ComposeSentence(parsedOption.ParsedName))]; + } + + // Private helpers. + private string ComposeSentence(string optName) => $"{optName} has max value {maxValue}."; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/MinValueOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/MinValueOptionRequirement.cs new file mode 100644 index 0000000..b0479c1 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/MinValueOptionRequirement.cs @@ -0,0 +1,55 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class MinValueOptionRequirement( + string optionsName, + double minValue) + : OptionRequirementBase([optionsName]) + { + // Methods. + public override string PrintHelpLine(CommandOptionsBase commandOptions) + { + ArgumentNullException.ThrowIfNull(commandOptions, nameof(commandOptions)); + + return ComposeSentence(commandOptions.FindOptionByName(OptionsNames.First()).LongName); + } + + public override IEnumerable ValidateOptions( + CommandOptionsBase commandOptions, + IEnumerable parsedOptions) + { + var optName = OptionsNames.First(); + + if (!TryFindParsedOption(parsedOptions, optName, out var parsedOption)) + return Array.Empty(); + + if (!double.TryParse(parsedOption!.ParsedArgs.First(), out var doubleArg)) + return [new OptionRequirementError( + $"Invalid argument value: {parsedOption.ParsedName} {parsedOption.ParsedArgs.First()}")]; + + return doubleArg >= minValue + ? Array.Empty() + : [new OptionRequirementError(ComposeSentence(parsedOption.ParsedName))]; + } + + // Private helpers. + private string ComposeSentence(string optName) => $"{optName} has min value {minValue}."; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/OptionRequirementBase.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/OptionRequirementBase.cs new file mode 100644 index 0000000..e4afe9d --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/OptionRequirementBase.cs @@ -0,0 +1,46 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public abstract class OptionRequirementBase( + IReadOnlyCollection optionsNames) + { + // Properties. + public IReadOnlyCollection OptionsNames { get; protected set; } = optionsNames; + + // Methods. + public abstract string PrintHelpLine( + CommandOptionsBase commandOptions); + + public abstract IEnumerable ValidateOptions( + CommandOptionsBase commandOptions, + IEnumerable parsedOptions); + + // Protected helpers. + protected static bool TryFindParsedOption( + IEnumerable parsedOptions, + string optionName, + out ParsedOption? foundParsedOption) + { + foundParsedOption = parsedOptions.SingleOrDefault(parsOpt => + parsOpt.Option.ShortName == optionName || + parsOpt.Option.LongName == optionName); + return foundParsedOption != null; + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/OptionRequirementError.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/OptionRequirementError.cs new file mode 100644 index 0000000..d8c7405 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/OptionRequirementError.cs @@ -0,0 +1,28 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class OptionRequirementError + { + // Constructor. + public OptionRequirementError(string message) + { + Message = message; + } + + // Properties. + public string Message { get; } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/RangeOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/RangeOptionRequirement.cs new file mode 100644 index 0000000..747a453 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/RangeOptionRequirement.cs @@ -0,0 +1,68 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class RangeOptionRequirement : OptionRequirementBase + { + // Constructor. + public RangeOptionRequirement(string optionsName, + double minValue, + double maxValue) : base([optionsName]) + { + if (minValue >= maxValue) + throw new ArgumentException("Min value must be smaller than max value"); + + MaxValue = maxValue; + MinValue = minValue; + } + + // Properties. + public double MaxValue { get; } + public double MinValue { get; } + + // Methods. + public override string PrintHelpLine(CommandOptionsBase commandOptions) + { + ArgumentNullException.ThrowIfNull(commandOptions, nameof(commandOptions)); + + return ComposeSentence(commandOptions.FindOptionByName(OptionsNames.First()).LongName); + } + + public override IEnumerable ValidateOptions( + CommandOptionsBase commandOptions, + IEnumerable parsedOptions) + { + var optName = OptionsNames.First(); + + if (!TryFindParsedOption(parsedOptions, optName, out var parsedOption)) + return Array.Empty(); + + if (!double.TryParse(parsedOption!.ParsedArgs.First(), out var doubleArg)) + return [new OptionRequirementError( + $"Invalid argument value: {parsedOption.ParsedName} {parsedOption.ParsedArgs.First()}")]; + + return doubleArg >= MinValue && doubleArg <= MaxValue + ? Array.Empty() + : [new OptionRequirementError(ComposeSentence(parsedOption.ParsedName))]; + } + + // Private helpers. + private string ComposeSentence(string optName) => $"{optName} has value in range [{MinValue}, {MaxValue}]."; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/OptionRequirements/RequireOneOfOptionRequirement.cs b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/RequireOneOfOptionRequirement.cs new file mode 100644 index 0000000..a481e10 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/OptionRequirements/RequireOneOfOptionRequirement.cs @@ -0,0 +1,35 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Etherna.GatewayCli.Models.Commands.OptionRequirements +{ + public class RequireOneOfOptionRequirement(params string[] optionsNames) + : OptionRequirementBase(optionsNames) + { + public override string PrintHelpLine(CommandOptionsBase commandOptions) => + string.Join(", ", OptionsNames.Select(n => commandOptions.FindOptionByName(n).LongName)) + + (OptionsNames.Count == 1 ? " is required." : " at least one is required."); + + public override IEnumerable ValidateOptions( + CommandOptionsBase commandOptions, + IEnumerable parsedOptions) => + OptionsNames.Any(optName => TryFindParsedOption(parsedOptions, optName, out _)) ? + Array.Empty() : + [new OptionRequirementError(PrintHelpLine(commandOptions))]; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Commands/ParsedOption.cs b/src/EthernaGatewayCli/Models/Commands/ParsedOption.cs new file mode 100644 index 0000000..d11e562 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Commands/ParsedOption.cs @@ -0,0 +1,30 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Etherna.GatewayCli.Models.Commands +{ + public class ParsedOption( + CommandOption option, + string parsedName, + params string[] parsedArgs) + { + // Properties. + public CommandOption Option { get; } = option; + public ReadOnlyCollection ParsedArgs { get; } = parsedArgs.AsReadOnly(); + public string ParsedName { get; } = parsedName; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/Domain/BzzBalance.cs b/src/EthernaGatewayCli/Models/Domain/BzzBalance.cs new file mode 100644 index 0000000..d81d896 --- /dev/null +++ b/src/EthernaGatewayCli/Models/Domain/BzzBalance.cs @@ -0,0 +1,89 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; + +namespace Etherna.GatewayCli.Models.Domain +{ + public struct BzzBalance : IEquatable + { + // Consts. + public const int DecimalPrecision = 16; + + // Fields. + private readonly decimal _balance; + + // Constructors. + public BzzBalance(decimal balance) + { + _balance = decimal.Round(balance, DecimalPrecision) + / 1.000000000000000000000000000000000m; //remove final zeros + } + + // Methods. + public int CompareTo(BzzBalance other) => _balance.CompareTo(other._balance); + + public override bool Equals(object? obj) => + obj is BzzBalance xDaiObj && + Equals(xDaiObj); + + public bool Equals(BzzBalance other) => _balance == other._balance; + public BzzBalance FromDecimal(decimal value) => new(value); + public BzzBalance FromDouble(double value) => new((decimal)value); + public BzzBalance FromInt32(int value) => new(value); + public override int GetHashCode() => _balance.GetHashCode(); + public decimal ToDecimal() => _balance; + public override string ToString() => _balance.ToString(CultureInfo.InvariantCulture); + + // Static methods. + public static BzzBalance Add(BzzBalance left, BzzBalance right) => left + right; + public static BzzBalance Decrement(BzzBalance balance) => --balance; + public static BzzBalance Divide(BzzBalance left, BzzBalance right) => left / right; + public static BzzBalance Increment(BzzBalance balance) => ++balance; + public static BzzBalance Multiply(BzzBalance left, BzzBalance right) => left * right; + public static BzzBalance Negate(BzzBalance balance) => -balance; + public static BzzBalance Subtract(BzzBalance left, BzzBalance right) => left - right; + + // Operator methods. + public static BzzBalance operator +(BzzBalance left, BzzBalance right) => + new(left._balance + right._balance); + + public static BzzBalance operator -(BzzBalance left, BzzBalance right) => + new(left._balance - right._balance); + + public static BzzBalance operator *(BzzBalance left, BzzBalance right) => + new(left._balance * right._balance); + + public static BzzBalance operator /(BzzBalance left, BzzBalance right) => + new(left._balance / right._balance); + + public static bool operator ==(BzzBalance left, BzzBalance right) => left.Equals(right); + public static bool operator !=(BzzBalance left, BzzBalance right) => !(left == right); + public static bool operator >(BzzBalance left, BzzBalance right) => left._balance > right._balance; + public static bool operator <(BzzBalance left, BzzBalance right) => left._balance < right._balance; + public static bool operator >=(BzzBalance left, BzzBalance right) => left._balance >= right._balance; + public static bool operator <=(BzzBalance left, BzzBalance right) => left._balance <= right._balance; + public static BzzBalance operator -(BzzBalance value) => new(-value._balance); + public static BzzBalance operator ++(BzzBalance value) => new(value._balance + 1); + public static BzzBalance operator --(BzzBalance value) => new(value._balance - 1); + + // Implicit conversion operator methods. + public static implicit operator BzzBalance(decimal value) => new(value); + public static implicit operator BzzBalance(double value) => new((decimal)value); + public static implicit operator BzzBalance(int value) => new(value); + + public static explicit operator decimal(BzzBalance value) => value.ToDecimal(); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Models/GitHubDto/GitReleaseVersionDto.cs b/src/EthernaGatewayCli/Models/GitHubDto/GitReleaseVersionDto.cs new file mode 100644 index 0000000..4d97cfe --- /dev/null +++ b/src/EthernaGatewayCli/Models/GitHubDto/GitReleaseVersionDto.cs @@ -0,0 +1,33 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Etherna.GatewayCli.Models.GitHubDto +{ + public class GitReleaseVersionDto + { + // Properties. + public string Assets_url { get; set; } = default!; + public DateTime Created_at { get; set; } + public bool Draft { get; set; } + public string Html_url { get; set; } = default!; + public int Id { get; set; } = default!; + public string Name { get; set; } = default!; + public bool Prerelease { get; set; } + public DateTime Published_at { get; set; } + public string Tag_name { get; set; } = default!; + public string Url { get; set; } = default!; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Program.cs b/src/EthernaGatewayCli/Program.cs index 989a6d2..b6b3aa3 100644 --- a/src/EthernaGatewayCli/Program.cs +++ b/src/EthernaGatewayCli/Program.cs @@ -1,9 +1,124 @@ -namespace EthernaGatewayCli; +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -class Program +using Etherna.GatewayCli.Commands; +using Etherna.GatewayCli.Models.Commands; +using Etherna.GatewayCli.Services; +using Etherna.Sdk.Users.Native; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli { - static void Main(string[] args) + internal sealed class Program { - Console.WriteLine("Hello, World!"); + // Consts. + private static readonly string[] ApiScopes = ["userApi.gateway"]; + private const string CommandsNamespace = "Etherna.GatewayCli.Commands"; + + // Methods. + public static async Task Main(string[] args) + { + // Setup DI. + var services = new ServiceCollection(); + + //commands + var availableCommandTypes = typeof(Program).GetTypeInfo().Assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && + t.Namespace?.StartsWith(CommandsNamespace) == true && + typeof(CommandBase).IsAssignableFrom(t)) + .OrderBy(t => t.Name); + foreach (var commandType in availableCommandTypes) + services.AddTransient(commandType); + + //services + services.AddCoreServices(); + + /**** + * WORKAROUND + * See: https://etherna.atlassian.net/browse/EAUTH-21 + * We need to configure the authentication method before of create Service Provider. + * Because of this, we decided to run only option parsing upfront, and so instantiate the real command. + */ + var ethernaCommandOptions = new EthernaCommandOptions(); + var tmpIoService = new ConsoleIoService(); +#pragma warning disable CA1031 + try + { + if (args.Length != 1 || (args[0] != "-h" && args[0] != "--help")) + ethernaCommandOptions.ParseArgs(args, tmpIoService); + } + catch (Exception e) + { + tmpIoService.WriteLine(); + tmpIoService.WriteLine(e.ToString()); + return; + } +#pragma warning restore CA1031 + /* END WORKAROUND + ****/ + + // Register etherna service clients. + IEthernaUserClientsBuilder ethernaClientsBuilder; + if (ethernaCommandOptions.ApiKey is null) //"code" grant flow + { + ethernaClientsBuilder = services.AddEthernaUserClientsWithCodeAuth( + CommonConsts.EthernaSsoUrl, + CommonConsts.EthernaGatewayCliClientId, + null, + 11430, + ApiScopes, + CommonConsts.HttpClientName, + c => + { + c.Timeout = TimeSpan.FromMinutes(30); + }); + } + else //"password" grant flow + { + ethernaClientsBuilder = services.AddEthernaUserClientsWithApiKeyAuth( + CommonConsts.EthernaSsoUrl, + ethernaCommandOptions.ApiKey, + ApiScopes, + CommonConsts.HttpClientName, + c => + { + c.Timeout = TimeSpan.FromMinutes(30); + }); + } + ethernaClientsBuilder.AddEthernaGatewayClient(new Uri(CommonConsts.EthernaGatewayUrl)); + + var serviceProvider = services.BuildServiceProvider(); + + // Start etherna command. + var ethernaCommand = serviceProvider.GetRequiredService(); + var ioService = serviceProvider.GetRequiredService(); + +#pragma warning disable CA1031 + try + { + await ethernaCommand.RunAsync(args); + } + catch (Exception e) + { + ioService.WriteLine(); + ioService.WriteLine(e.ToString()); + } +#pragma warning restore CA1031 + } } -} \ No newline at end of file +} diff --git a/src/EthernaGatewayCli/ServiceCollectionExtensions.cs b/src/EthernaGatewayCli/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5311d4d --- /dev/null +++ b/src/EthernaGatewayCli/ServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.BeeNet; +using Etherna.BeeNet.Clients.GatewayApi; +using Etherna.GatewayCli.Services; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Net.Http; + +namespace Etherna.GatewayCli +{ + internal static class ServiceCollectionExtensions + { + public static void AddCoreServices( + this IServiceCollection services) + { + // Add transient services. + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Add singleton services. + //bee.net + services.AddSingleton((sp) => + { + var httpClientFactory = sp.GetRequiredService(); + return new BeeGatewayClient( + httpClientFactory.CreateClient(CommonConsts.HttpClientName), + new Uri(CommonConsts.EthernaGatewayUrl)); + }); + services.AddSingleton(); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/AuthenticationService.cs b/src/EthernaGatewayCli/Services/AuthenticationService.cs new file mode 100644 index 0000000..22f5217 --- /dev/null +++ b/src/EthernaGatewayCli/Services/AuthenticationService.cs @@ -0,0 +1,65 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.Authentication; +using Etherna.Authentication.Native; +using System; +using System.ComponentModel; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Services +{ + public class AuthenticationService : IAuthenticationService + { + // Fields. + private readonly IEthernaOpenIdConnectClient ethernaOpenIdConnectClient; + private readonly IEthernaSignInService ethernaSignInService; + private readonly IIoService ioService; + + // Constructor. + public AuthenticationService( + IEthernaOpenIdConnectClient ethernaOpenIdConnectClient, + IEthernaSignInService ethernaSignInService, + IIoService ioService) + { + this.ethernaOpenIdConnectClient = ethernaOpenIdConnectClient; + this.ethernaSignInService = ethernaSignInService; + this.ioService = ioService; + } + + // Methods. + public async Task SignInAsync() + { + try + { + await ethernaSignInService.SignInAsync(); + } + catch (InvalidOperationException) + { + ioService.WriteErrorLine("Error during authentication."); + throw; + } + catch (Win32Exception) + { + ioService.WriteErrorLine("Error opening browser on local system. Try to authenticate with API key."); + throw; + } + + // Get info from authenticated user. + var userName = await ethernaOpenIdConnectClient.GetUsernameAsync(); + + ioService.WriteLine($"User {userName} authenticated"); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/ConsoleIoService.cs b/src/EthernaGatewayCli/Services/ConsoleIoService.cs new file mode 100644 index 0000000..b818bb4 --- /dev/null +++ b/src/EthernaGatewayCli/Services/ConsoleIoService.cs @@ -0,0 +1,47 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Etherna.GatewayCli.Services +{ + public class ConsoleIoService : IIoService + { + // Consts. + private const ConsoleColor ErrorForegroundColor = ConsoleColor.DarkRed; + + // Methods. + public ConsoleKeyInfo ReadKey() => Console.ReadKey(); + + public string? ReadLine() => Console.ReadLine(); + + public void Write(string? value) => Console.Write(value); + + public void WriteError(string value) + { + Console.ForegroundColor = ErrorForegroundColor; + Console.Write(value); + Console.ResetColor(); + } + + public void WriteErrorLine(string value) + { + Console.ForegroundColor = ErrorForegroundColor; + Console.WriteLine(value); + Console.ResetColor(); + } + + public void WriteLine(string? value = null) => Console.WriteLine(value); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/FileService.cs b/src/EthernaGatewayCli/Services/FileService.cs new file mode 100644 index 0000000..6e9df66 --- /dev/null +++ b/src/EthernaGatewayCli/Services/FileService.cs @@ -0,0 +1,52 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.AspNetCore.StaticFiles; +using MimeDetective; +using System.Linq; + +namespace Etherna.GatewayCli.Services +{ + public class FileService : IFileService + { + // Consts. + const string DefaultContentType = "application/octet-stream"; + + // Fields. + private readonly FileExtensionContentTypeProvider extensionMimeTypeProvider = new(); + private readonly ContentInspector contentMimeTypeProvider = new ContentInspectorBuilder + { + Definitions = MimeDetective.Definitions.Default.All() + }.Build(); + + // Methods. + public string GetMimeType(string filePath) + { + var contentType = GetMimeTypeFromExtension(filePath); + return contentType == DefaultContentType ? GetMimeTypeFromContent(filePath) : contentType; + } + + public string GetMimeTypeFromContent(string filePath) + { + var result = contentMimeTypeProvider.Inspect(filePath); + var mimeTypes = result.ByMimeType().Select(mtm => mtm.MimeType); + return mimeTypes.FirstOrDefault() ?? DefaultContentType; + } + + public string GetMimeTypeFromExtension(string filePath) => + extensionMimeTypeProvider.TryGetContentType(filePath, out string? contentType) + ? contentType + : DefaultContentType; + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/GatewayService.cs b/src/EthernaGatewayCli/Services/GatewayService.cs new file mode 100644 index 0000000..753e241 --- /dev/null +++ b/src/EthernaGatewayCli/Services/GatewayService.cs @@ -0,0 +1,176 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.BeeNet.Clients.GatewayApi; +using Etherna.GatewayCli.Models.Domain; +using Etherna.Sdk.GeneratedClients.Gateway; +using Etherna.Sdk.Users; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Services +{ + public class GatewayService : IGatewayService + { + // Const. + private readonly TimeSpan BatchCheckTimeSpan = new(0, 0, 0, 5); + private readonly TimeSpan BatchCreationTimeout = new(0, 0, 10, 0); + private readonly TimeSpan BatchUsableTimeout = new(0, 0, 10, 0); + private readonly long BzzDecimalPlacesToUnit = (long)Math.Pow(10, 16); + private const int ChunkByteSize = 4096; + private const int MinBatchDepth = 17; + + // Fields. + private readonly IBeeGatewayClient beeGatewayClient; + private readonly IEthernaUserGatewayClient ethernaGatewayClient; + private readonly IIoService ioService; + + // Constructor. + public GatewayService( + IBeeGatewayClient beeGatewayClient, + IEthernaUserGatewayClient ethernaGatewayClient, + IIoService ioService) + { + this.beeGatewayClient = beeGatewayClient; + this.ethernaGatewayClient = ethernaGatewayClient; + this.ioService = ioService; + } + + // Methods. + public async Task CalculateAmountAsync(TimeSpan ttl) + { + var currentPrice = await GetCurrentChainPriceAsync(); + return (long)(ttl.TotalSeconds * currentPrice / CommonConsts.GnosisBlockTime.TotalSeconds); + } + + public BzzBalance CalculateBzzPrice(long amount, int depth) => + amount * Math.Pow(2, depth) / BzzDecimalPlacesToUnit; + + public int CalculateDepth(long contentByteSize) + { + var batchDepth = 17; + while (Math.Pow(2, batchDepth) * ChunkByteSize < CalculateRequiredPostageBatchSpace(contentByteSize)) + batchDepth++; + return batchDepth; + } + + public long CalculatePostageBatchByteSize(PostageBatchDto postageBatch) + { + ArgumentNullException.ThrowIfNull(postageBatch, nameof(postageBatch)); + return (long)Math.Pow(2, postageBatch.Depth) * ChunkByteSize; + } + + public long CalculateRequiredPostageBatchSpace(long contentByteSize) => + (long)(contentByteSize * 1.2); //keep 20% of tolerance + + public async Task CreatePostageBatchAsync(long amount, int batchDepth, string? label) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + if (batchDepth < MinBatchDepth) + throw new ArgumentException($"Postage depth must be at least {MinBatchDepth}"); + + // Start creation. + var bzzPrice = CalculateBzzPrice(amount, batchDepth); + ioService.WriteLine($"Creating postage batch... Depth: {batchDepth}, Amount: {amount}, BZZ price: {bzzPrice}"); + var batchReferenceId = await ethernaGatewayClient.UsersClient.BatchesPostAsync(batchDepth, amount, label); + + // Wait until created batch is available. + ioService.Write("Waiting for batch created... (it may take a while)"); + + var batchStartWait = DateTime.UtcNow; + string? batchId = null; + do + { + //timeout throw exception + if (DateTime.UtcNow - batchStartWait >= BatchCreationTimeout) + { + var ex = new InvalidOperationException("Batch not avaiable after timeout"); + ex.Data.Add("BatchReferenceId", batchReferenceId); + throw ex; + } + + try + { + batchId = await ethernaGatewayClient.SystemClient.PostageBatchRefAsync(batchReferenceId); + } + catch (EthernaGatewayApiException) + { + //waiting for batchId available + await Task.Delay(BatchCheckTimeSpan); + } + } while (string.IsNullOrWhiteSpace(batchId)); + + ioService.WriteLine(". Done"); + + await WaitForBatchUsableAsync(batchId); + + return batchId; + } + + public Task FundResourcePinningAsync(string hash) => + ethernaGatewayClient.ResourcesClient.PinPostAsync(hash); + + public Task FundResourceTrafficAsync(string hash) => + ethernaGatewayClient.ResourcesClient.OffersPostAsync(hash); + + public async Task GetCurrentChainPriceAsync() => + (await ethernaGatewayClient.SystemClient.ChainstateAsync()).CurrentPrice; + + public Task GetPostageBatchInfoAsync(string batchId) => + ethernaGatewayClient.UsersClient.BatchesGetAsync(batchId); + + public Task UploadFileAsync( + string postageBatchId, + Stream content, + string? name, + string? contentType, + bool pinResource) => + beeGatewayClient.UploadFileAsync( + postageBatchId, + content, + name: name, + contentType: contentType, + swarmDeferredUpload: true, + swarmPin: pinResource); + + // Helpers. + private async Task WaitForBatchUsableAsync(string batchId) + { + // Wait until created batch is usable. + ioService.Write("Waiting for batch being usable... (it may take a while)"); + + var batchStartWait = DateTime.UtcNow; + bool batchIsUsable; + do + { + //timeout throw exception + if (DateTime.UtcNow - batchStartWait >= BatchUsableTimeout) + { + var ex = new InvalidOperationException("Batch not usable after timeout"); + ex.Data.Add("BatchId", batchId); + throw ex; + } + + batchIsUsable = (await GetPostageBatchInfoAsync(batchId)).Usable; + + //waiting for batch usable + await Task.Delay(BatchCheckTimeSpan); + } while (!batchIsUsable); + + ioService.WriteLine(". Done"); + } + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/IAuthenticationService.cs b/src/EthernaGatewayCli/Services/IAuthenticationService.cs new file mode 100644 index 0000000..c7b7540 --- /dev/null +++ b/src/EthernaGatewayCli/Services/IAuthenticationService.cs @@ -0,0 +1,23 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Services +{ + public interface IAuthenticationService + { + Task SignInAsync(); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/IFileService.cs b/src/EthernaGatewayCli/Services/IFileService.cs new file mode 100644 index 0000000..28ab6b7 --- /dev/null +++ b/src/EthernaGatewayCli/Services/IFileService.cs @@ -0,0 +1,25 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Etherna.GatewayCli.Services +{ + public interface IFileService + { + string GetMimeType(string filePath); + + string GetMimeTypeFromContent(string filePath); + + string GetMimeTypeFromExtension(string filePath); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/IGatewayService.cs b/src/EthernaGatewayCli/Services/IGatewayService.cs new file mode 100644 index 0000000..fca3630 --- /dev/null +++ b/src/EthernaGatewayCli/Services/IGatewayService.cs @@ -0,0 +1,42 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.Domain; +using Etherna.Sdk.GeneratedClients.Gateway; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Services +{ + public interface IGatewayService + { + Task CalculateAmountAsync(TimeSpan ttl); + BzzBalance CalculateBzzPrice(long amount, int depth); + int CalculateDepth(long contentByteSize); + long CalculatePostageBatchByteSize(PostageBatchDto postageBatch); + long CalculateRequiredPostageBatchSpace(long contentByteSize); + Task CreatePostageBatchAsync(long amount, int batchDepth, string? label); + Task FundResourcePinningAsync(string hash); + Task FundResourceTrafficAsync(string hash); + Task GetCurrentChainPriceAsync(); + Task GetPostageBatchInfoAsync(string batchId); + Task UploadFileAsync( + string postageBatchId, + Stream content, + string? name, + string? contentType, + bool pinResource); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Services/IIoService.cs b/src/EthernaGatewayCli/Services/IIoService.cs new file mode 100644 index 0000000..7b66049 --- /dev/null +++ b/src/EthernaGatewayCli/Services/IIoService.cs @@ -0,0 +1,28 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Etherna.GatewayCli.Services +{ + public interface IIoService + { + ConsoleKeyInfo ReadKey(); + string? ReadLine(); + void Write(string? value); + void WriteError(string value); + void WriteErrorLine(string value); + void WriteLine(string? value = null); + } +} \ No newline at end of file diff --git a/src/EthernaGatewayCli/Utilities/EthernaVersionControl.cs b/src/EthernaGatewayCli/Utilities/EthernaVersionControl.cs new file mode 100644 index 0000000..d3bda7d --- /dev/null +++ b/src/EthernaGatewayCli/Utilities/EthernaVersionControl.cs @@ -0,0 +1,106 @@ +// Copyright 2024-present Etherna SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Etherna.GatewayCli.Models.GitHubDto; +using Etherna.GatewayCli.Services; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Reflection; +using System.Threading.Tasks; + +namespace Etherna.GatewayCli.Utilities +{ + public static class EthernaVersionControl + { + // Fields. + private static Version? _currentVersion; + + // Properties. + [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations")] + public static Version CurrentVersion + { + get + { + if (_currentVersion is null) + { + var assemblyVersion = Assembly.GetExecutingAssembly().GetCustomAttribute()?.Version ?? + throw new InvalidOperationException("Invalid assembly version"); + _currentVersion = new Version(assemblyVersion); + } + return _currentVersion; + } + } + + // Public methods. + public static async Task CheckNewVersionAsync( + IIoService ioService) + { + ArgumentNullException.ThrowIfNull(ioService, nameof(ioService)); + + // Get current version. + ioService.WriteLine($"Etherna Gateway CLI (v{CurrentVersion})"); + ioService.WriteLine(); + + // Get last version form github releases. + try + { + using HttpClient httpClient = new(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "EthernaImportClient"); + var gitUrl = "https://api.github.com/repos/Etherna/etherna-gateway-cli/releases"; + var response = await httpClient.GetAsync(gitUrl); + var gitReleaseVersionsDto = await response.Content.ReadFromJsonAsync>(); + + if (gitReleaseVersionsDto is null || gitReleaseVersionsDto.Count == 0) + return false; + + var lastVersion = gitReleaseVersionsDto + .Select(git => new + { + Version = new Version(git.Tag_name.Replace("v", "", StringComparison.OrdinalIgnoreCase)), + Url = git.Html_url + }) + .OrderByDescending(v => v.Version) + .First(); + + if (lastVersion.Version > CurrentVersion) + { + ioService.WriteLine( + $""" + A new release is available: {lastVersion.Version} + Upgrade now, or check out the release page at: + {lastVersion.Url} + """); + return true; + } + else + return false; + } +#pragma warning disable CA1031 + catch (Exception ex) +#pragma warning restore CA1031 + { + ioService.WriteErrorLine( + $""" + Unable to check last version on GitHub + Error: {ex.Message} + """); + return false; + } + } + } +} \ No newline at end of file