Skip to content

Commit

Permalink
Merge pull request #395 from WildernessLabs/feature/mc-apikeys
Browse files Browse the repository at this point in the history
Add management of Meadow.Cloud API keys
  • Loading branch information
adrianstevens authored Jan 18, 2024
2 parents 843ee0a + cb850c2 commit ee2551a
Show file tree
Hide file tree
Showing 14 changed files with 522 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using Meadow.Cloud;
using Meadow.Cloud.Identity;
using Microsoft.Extensions.Logging;

namespace Meadow.CLI.Commands.DeviceManagement;

[Command("cloud apikey create", Description = "Create a Meadow.Cloud API key")]
public class CloudApiKeyCreateCommand : BaseCloudCommand<CloudApiKeyCreateCommand>
{
[CommandParameter(0, Description = "The name of the API key", IsRequired = true, Name = "NAME")]
public string? Name { get; set; }

[CommandOption("duration", 'd', Description = "The duration of the API key, in days", IsRequired = true)]
public int Duration { get; set; }

[CommandOption("scopes", 's', Description = "The list of scopes (permissions) to grant the API key", IsRequired = true)]
public string[]? Scopes { get; set; }

[CommandOption("host", Description = $"Optionally set a host (default is {DefaultHost})")]
public string? Host { get; set; }

private ApiTokenService ApiTokenService { get; }

public CloudApiKeyCreateCommand(
ApiTokenService apiTokenService,
CollectionService collectionService,
DeviceService deviceService,
IdentityManager identityManager,
UserService userService,
ILoggerFactory? loggerFactory)
: base(identityManager, userService, deviceService, collectionService, loggerFactory)
{
ApiTokenService = apiTokenService;
}

protected async override ValueTask ExecuteCommand()
{
if (Duration < 1 || Duration > 90)
{
throw new CommandException("Duration (-d|--duration) must be between 1 and 90 days.", showHelp: true);
}

Host ??= DefaultHost;

Logger?.LogInformation($"Creating an API key on Meadow.Cloud{(Host != DefaultHost ? $" ({Host.ToLowerInvariant()})" : string.Empty)}...");

var token = await IdentityManager.GetAccessToken(CancellationToken);
if (string.IsNullOrWhiteSpace(token))
{
throw new CommandException("You must be signed into Meadow.Cloud to execute this command. Run 'meadow cloud login' to do so.");
}

try
{
var request = new CreateApiTokenRequest(Name!, Duration, Scopes!);
var response = await ApiTokenService.CreateApiToken(request, Host, CancellationToken);

Logger?.LogInformation($"Your API key '{response.Name}' (expiring {response.ExpiresAt:G} UTC) is:");
Logger?.LogInformation($"\n{response.Token}\n");
Logger?.LogInformation("Make sure to copy this key now as you will not be able to see this again.");
}
catch (MeadowCloudAuthException ex)
{
throw new CommandException("You must be signed in to execute this command.", innerException: ex);
}
catch (MeadowCloudException ex)
{
throw new CommandException($"Create API key command failed: {ex.Message}", innerException: ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using Meadow.Cloud;
using Meadow.Cloud.Identity;
using Microsoft.Extensions.Logging;

namespace Meadow.CLI.Commands.DeviceManagement;

[Command("cloud apikey delete", Description = "Delete a Meadow.Cloud API key")]
public class CloudApiKeyDeleteCommand : BaseCloudCommand<CloudApiKeyDeleteCommand>
{
[CommandParameter(0, Description = "The name or ID of the API key", IsRequired = true, Name = "NAME_OR_ID")]
public string? NameOrId { get; set; }

[CommandOption("host", Description = $"Optionally set a host (default is {DefaultHost})", IsRequired = false)]
public string? Host { get; set; }

private ApiTokenService ApiTokenService { get; }

public CloudApiKeyDeleteCommand(
ApiTokenService apiTokenService,
CollectionService collectionService,
DeviceService deviceService,
IdentityManager identityManager,
UserService userService,
ILoggerFactory? loggerFactory)
: base(identityManager, userService, deviceService, collectionService, loggerFactory)
{
ApiTokenService = apiTokenService;
}

protected async override ValueTask ExecuteCommand()
{
Host ??= DefaultHost;

Logger?.LogInformation($"Deleting API key `{NameOrId}` on Meadow.Cloud{(Host != DefaultHost ? $" ({Host.ToLowerInvariant()})" : string.Empty)}...");

var token = await IdentityManager.GetAccessToken(CancellationToken);
if (string.IsNullOrWhiteSpace(token))
{
throw new CommandException("You must be signed into Meadow.Cloud to execute this command. Run 'meadow cloud login' to do so.");
}

try
{
var getRequest = await ApiTokenService.GetApiTokens(Host, CancellationToken);
var apiKey = getRequest.FirstOrDefault(x => x.Id == NameOrId || string.Equals(x.Name, NameOrId, StringComparison.OrdinalIgnoreCase));

if (apiKey == null)
{
throw new CommandException($"API key `{NameOrId}` not found.");
}

await ApiTokenService.DeleteApiToken(apiKey.Id, Host, CancellationToken);
}
catch (MeadowCloudAuthException ex)
{
throw new CommandException("You must be signed in to execute this command.", innerException: ex);
}
catch (MeadowCloudException ex)
{
throw new CommandException($"Create API key command failed: {ex.Message}", innerException: ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using Meadow.Cloud;
using Meadow.Cloud.Identity;
using Microsoft.Extensions.Logging;

namespace Meadow.CLI.Commands.DeviceManagement;

[Command("cloud apikey list", Description = "List your Meadow.Cloud API keys")]
public class CloudApiKeyListCommand : BaseCloudCommand<CloudApiKeyListCommand>
{
[CommandOption("host", Description = $"Optionally set a host (default is {DefaultHost})", IsRequired = false)]
public string? Host { get; set; }

private ApiTokenService ApiTokenService { get; }

public CloudApiKeyListCommand(
ApiTokenService apiTokenService,
CollectionService collectionService,
DeviceService deviceService,
IdentityManager identityManager,
UserService userService,
ILoggerFactory? loggerFactory)
: base(identityManager, userService, deviceService, collectionService, loggerFactory)
{
ApiTokenService = apiTokenService;
}

protected override async ValueTask ExecuteCommand()
{
Host ??= DefaultHost;

Logger?.LogInformation($"Retrieving your API keys from Meadow.Cloud{(Host != DefaultHost ? $" ({Host.ToLowerInvariant()})" : string.Empty)}...");

var token = await IdentityManager.GetAccessToken(CancellationToken);
if (string.IsNullOrWhiteSpace(token))
{
throw new CommandException("You must be signed into Meadow.Cloud to execute this command. Run 'meadow cloud login' to do so.");
}

try
{
var response = await ApiTokenService.GetApiTokens(Host, CancellationToken);
var apiTokens = response.OrderBy(a => a.Name);

if (!apiTokens.Any())
{
Logger?.LogInformation("You have no API keys.");
return;
}

var table = new ConsoleTable("Id", "Name", $"Expires (UTC)", "Scopes");
foreach (var apiToken in apiTokens)
{
table.AddRow(apiToken.Id, apiToken.Name, $"{apiToken.ExpiresAt:G}", string.Join(", ", apiToken.Scopes.OrderBy(t => t)));
}

Logger?.LogInformation(table);
}
catch (MeadowCloudAuthException ex)
{
throw new CommandException("You must be signed in to execute this command.", innerException: ex);
}
catch (MeadowCloudException ex)
{
throw new CommandException($"Get API keys command failed: {ex.Message}", innerException: ex);
}
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using Meadow.Cloud;
using Meadow.Cloud.Identity;
using Microsoft.Extensions.Logging;

namespace Meadow.CLI.Commands.DeviceManagement;

[Command("cloud apikey update", Description = "Update a Meadow.Cloud API key")]
public class CloudApiKeyUpdateCommand : BaseCloudCommand<CloudApiKeyUpdateCommand>
{
[CommandParameter(0, Description = "The name or ID of the API key", IsRequired = true, Name = "NAME_OR_ID")]
public string? NameOrId { get; set; }

[CommandOption("name", 'n', Description = "The new name to use for the API key")]
public string? NewName { get; set; }

[CommandOption("scopes", 's', Description = "The list of scopes (permissions) to grant the API key")]
public string[]? Scopes { get; set; }

[CommandOption("host", Description = $"Optionally set a host (default is {DefaultHost})", IsRequired = false)]
public string? Host { get; set; }

private ApiTokenService ApiTokenService { get; }

public CloudApiKeyUpdateCommand(
ApiTokenService apiTokenService,
CollectionService collectionService,
DeviceService deviceService,
IdentityManager identityManager,
UserService userService,
ILoggerFactory? loggerFactory)
: base(identityManager, userService, deviceService, collectionService, loggerFactory)
{
ApiTokenService = apiTokenService;
}

protected async override ValueTask ExecuteCommand()
{
Host ??= DefaultHost;

Logger?.LogInformation($"Updating API key `{NameOrId}` on Meadow.Cloud{(Host != DefaultHost ? $" ({Host.ToLowerInvariant()})" : string.Empty)}...");

var token = await IdentityManager.GetAccessToken(CancellationToken);
if (string.IsNullOrWhiteSpace(token))
{
throw new CommandException("You must be signed into Meadow.Cloud to execute this command. Run 'meadow cloud login' to do so.");
}

try
{
var getRequest = await ApiTokenService.GetApiTokens(Host, CancellationToken);
var apiKey = getRequest.FirstOrDefault(x => x.Id == NameOrId || string.Equals(x.Name, NameOrId, StringComparison.OrdinalIgnoreCase));

if (apiKey == null)
{
throw new CommandException($"API key `{NameOrId}` not found.");
}

NewName ??= apiKey.Name;
Scopes ??= apiKey.Scopes;

var updateRequest = new UpdateApiTokenRequest(NewName!, Scopes!);
await ApiTokenService.UpdateApiToken(apiKey.Id, updateRequest, Host, CancellationToken);
}
catch (MeadowCloudAuthException ex)
{
throw new CommandException("You must be signed in to execute this command.", innerException: ex);
}
catch (MeadowCloudException ex)
{
throw new CommandException($"Create API key command failed: {ex.Message}", innerException: ex);
}
}
}
107 changes: 107 additions & 0 deletions Source/v2/Meadow.CLI/Commands/Current/Cloud/ConsoleTable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Text;

namespace Meadow.CLI;

public class ConsoleTable
{
private readonly string[] _columns;
private IList<string[]> _rows;

public ConsoleTable(params string[] columns)
{
_columns = columns;
_rows = new List<string[]>();
}

public void AddRow(params object[] values)
{
if (values.Length != _columns.Length)
{
throw new InvalidOperationException("The number of values for the given row does not match the number of columns.");
}

_rows.Add(values.Select(v => Convert.ToString(v) ?? string.Empty).ToArray());
}

public static implicit operator string(ConsoleTable table) => table.Render();

public string Render()
{
var maxWidths = new int[_columns.Length];
for (var i = 0; i < _columns.Length; i++)
{
maxWidths[i] = _columns[i].Length;
}

for (var i = 0; i < _rows.Count; i++)
{
for (var j = 0; j < _rows[i].Length; j++)
{
maxWidths[j] = Math.Max(maxWidths[j], _rows[i][j].Length);
}
}

var sb = new StringBuilder();

// Divider
sb.AppendLine();
for (var i = 0; i < _columns.Length; i++)
{
sb.Append(new string('-', maxWidths[i]));
if (i < _columns.Length - 1)
{
sb.Append("-+-");
}
}

// Header
sb.AppendLine();
for (var i = 0; i < _columns.Length; i++)
{
sb.Append(_columns[i].PadRight(maxWidths[i]));
if (i < _columns.Length - 1)
{
sb.Append(" | ");
}
}

// Divider
sb.AppendLine();
for (var i = 0; i < _columns.Length; i++)
{
sb.Append(new string('-', maxWidths[i]));
if (i < _columns.Length - 1)
{
sb.Append("-|-");
}
}

// Rows
for (var i = 0; i < _rows.Count; i++)
{
sb.AppendLine();
for (var j = 0; j < _rows[i].Length; j++)
{
sb.Append(_rows[i][j].PadRight(maxWidths[j]));
if (j < _rows[i].Length - 1)
{
sb.Append(" | ");
}
}
}

// Divider
sb.AppendLine();
for (var i = 0; i < _columns.Length; i++)
{
sb.Append(new string('-', maxWidths[i]));
if (i < _columns.Length - 1)
{
sb.Append("-+-");
}
}

sb.AppendLine();
return sb.ToString();
}
}
Loading

0 comments on commit ee2551a

Please sign in to comment.