Skip to content
This repository has been archived by the owner on Dec 25, 2024. It is now read-only.

Commit

Permalink
Sync workspaces into config
Browse files Browse the repository at this point in the history
This paves way for using the workspace name as an alternative to its identifier. It also allows us to validate the workspace before sending the request hence failing early.

The workspaces can be synced using new commands:
- `falu workspaces list [--refresh] [--all]`
- `falu workspaces show --name <name-or-id>`

After login, the workspaces are also synced which should ensure we have valid data every few (approximately 7) days.
  • Loading branch information
mburumaxwell committed Jun 3, 2024
1 parent 6437cbd commit c8c7cfd
Show file tree
Hide file tree
Showing 12 changed files with 255 additions and 12 deletions.
3 changes: 3 additions & 0 deletions src/FaluCli/Client/FaluCliClient.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Falu.Client.Events;
using Falu.Client.MoneyStatements;
using Falu.Client.Realtime;
using Falu.Client.Workspaces;
using Microsoft.Extensions.Options;

namespace Falu.Client;
Expand All @@ -13,9 +14,11 @@ public FaluCliClient(HttpClient backChannel, IOptionsSnapshot<FaluClientOptions>
Events = new ExtendedEventsServiceClient(BackChannel, Options);
MoneyStatements = new MoneyStatementsServiceClient(BackChannel, Options);
Realtime = new RealtimeServiceClient(BackChannel, Options);
Workspaces = new WorkspacesServiceClient(BackChannel, Options);
}

public new ExtendedEventsServiceClient Events { get; protected set; }
public MoneyStatementsServiceClient MoneyStatements { get; protected set; }
public RealtimeServiceClient Realtime { get; protected set; }
public WorkspacesServiceClient Workspaces { get; protected set; }
}
27 changes: 16 additions & 11 deletions src/FaluCli/Client/FaluCliClientHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,25 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
var key = parseResult.ValueForOption<string>("--apikey");
if (string.IsNullOrWhiteSpace(key))
{
// (1) Set the X-Workspace-Id header using the CLI option to override the default
if (!parseResult.TryGetWorkspaceId(out var workspaceId) && (workspaceId = configValues.DefaultWorkspaceId) == null)
// (1) Set the X-Workspace-Id and X-Live-Mode headers but skip for /workspaces
if (!request.RequestUri!.ToString().Contains("/workspaces"))
{
throw new FaluException(Res.MissingWorkspaceId);
}
request.Headers.Replace("X-Workspace-Id", workspaceId);
// (1a) Set the X-Workspace-Id header using the CLI option to override the default
if (parseResult.TryGetWorkspaceId(out var workspaceId))
{
// TODO: check if the workspace exists in the configuration
}
workspaceId ??= (workspaceId ?? configValues.DefaultWorkspaceId) ?? throw new FaluException(Res.MissingWorkspaceId);
request.Headers.Replace("X-Workspace-Id", workspaceId);

// (2) Set the X-Live-Mode header using CLI option to override the default
if (parseResult.TryGetLiveMode(out var live) || (live = configValues.DefaultLiveMode) != null)
{
request.Headers.Replace("X-Live-Mode", live.Value.ToString().ToLowerInvariant()); // when absent, the server assumes false
// (1b) Set the X-Live-Mode header using CLI option to override the default
if (parseResult.TryGetLiveMode(out var live) || (live = configValues.DefaultLiveMode) != null)
{
request.Headers.Replace("X-Live-Mode", live.Value.ToString().ToLowerInvariant()); // when absent, the server assumes false
}
}

// (3) Handle appropriate authentication
// (2) Handle appropriate authentication

// ensure we have login information and that it contains a valid access token or refresh token
if (configValues.Authentication is null || (!configValues.Authentication.HasValidAccessToken() && !configValues.Authentication.HasRefreshToken()))
Expand Down Expand Up @@ -65,7 +70,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
// at this point we have a key and we can proceed to set the authentication header
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);

// (5) Execute the modified request
// (3) Execute the modified request
return await base.SendAsync(request, cancellationToken);
}
}
15 changes: 15 additions & 0 deletions src/FaluCli/Client/Workspaces/Workspace.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Falu.Core;

namespace Falu.Client.Workspaces;

internal class Workspace : IHasId
{
/// <inheritdoc/>
public string? Id { get; set; }

public string? Name { get; set; }

public string? Status { get; set; }

public string? Role { get; set; }
}
6 changes: 6 additions & 0 deletions src/FaluCli/Client/Workspaces/WorkspacesListOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Falu.Core;

namespace Falu.Client.Workspaces;

/// <summary>Options for filtering and pagination of workspaces.</summary>
public record WorkspacesListOptions : BasicListOptions { } // intentionally left blank
30 changes: 30 additions & 0 deletions src/FaluCli/Client/Workspaces/WorkspacesServiceClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Falu.Core;
using SC = Falu.FaluCliJsonSerializerContext;

namespace Falu.Client.Workspaces;

internal class WorkspacesServiceClient(HttpClient backChannel, FaluClientOptions options) : BaseServiceClient<Workspace>(backChannel, options, SC.Default.Workspace, SC.Default.ListWorkspace),
ISupportsListing<Workspace, WorkspacesListOptions>
{
/// <inheritdoc/>
protected override string BasePath => "/v1/workspaces";


/// <summary>Retrieve workspaces.</summary>
/// <inheritdoc/>
public virtual Task<ResourceResponse<List<Workspace>>> ListAsync(WorkspacesListOptions? options = null,
RequestOptions? requestOptions = null,
CancellationToken cancellationToken = default)
{
return ListResourcesAsync(options, requestOptions, cancellationToken);
}

/// <summary>Retrieve workspaces recursively.</summary>
/// <inheritdoc/>
public virtual IAsyncEnumerable<Workspace> ListRecursivelyAsync(WorkspacesListOptions? options = null,
RequestOptions? requestOptions = null,
CancellationToken cancellationToken = default)
{
return ListResourcesRecursivelyAsync(options, requestOptions, cancellationToken);
}
}
23 changes: 23 additions & 0 deletions src/FaluCli/Commands/Login/LoginCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ public override async Task<int> ExecuteAsync(CliCommandExecutionContext context,
// set the authentication information
context.ConfigValues.Authentication = new ConfigValuesAuthenticationTokens(tokenResp);

// sync workspaces
context.Logger.LogInformation("Syncing workspaces ...");
var workspacesResp = await context.Client.Workspaces.ListAsync(cancellationToken: cancellationToken);
workspacesResp.EnsureSuccess();
var workspaces = workspacesResp.Resource!.Select(w => new ConfigValuesWorkspace(w)).ToList();
context.ConfigValues.Workspaces = workspaces;
context.Logger.LogInformation("Workspaces synced successfully.");

// update default workspace
if (workspaces.Count > 0)
{
var defaultWorkspaceId = context.ConfigValues.DefaultWorkspaceId;
if (defaultWorkspaceId is not null)
{
var workspace = context.ConfigValues.GetWorkspace(defaultWorkspaceId);
if (workspace is null)
{
context.Logger.LogInformation("Default workspace '{DefaultWorkspaceId}' not found. Resetting to null.", defaultWorkspaceId);
context.ConfigValues.DefaultWorkspaceId = null;
}
}
}

return 0;
}
}
1 change: 1 addition & 0 deletions src/FaluCli/Commands/Login/LogoutCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public LogoutCommand() : base("logout", "Logout of your Falu account from the CL
public override Task<int> ExecuteAsync(CliCommandExecutionContext context, CancellationToken cancellationToken)
{
context.ConfigValues.Authentication = null;
context.ConfigValues.Workspaces = [];
context.Logger.LogInformation("Authentication information cleared.");

return Task.FromResult(0);
Expand Down
110 changes: 110 additions & 0 deletions src/FaluCli/Commands/WorkspacesCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using Falu.Config;
using Spectre.Console;

namespace Falu.Commands;

internal class WorkspacesCommand : CliCommand
{
public WorkspacesCommand() : base("workspaces", "Manage workspaces")
{
Add(new WorkspacesListCommand());
Add(new WorkspacesShowCommand());
}
}

internal class WorkspacesListCommand : FaluCliCommand
{
public WorkspacesListCommand() : base("list", "Get a list of workspaces for the logged in account.\r\nBy default, 'Terminated' workspaces are not shown.")
{
this.AddOption(aliases: ["--all"],
description: "List all workspaces, rather than skipping 'Terminated' ones.",
defaultValue: false);

this.AddOption(aliases: ["--refresh"],
description: "Retrieve up-to-date workspaces from server.",
defaultValue: false);
}

public override async Task<int> ExecuteAsync(CliCommandExecutionContext context, CancellationToken cancellationToken)
{
var all = context.ParseResult.ValueForOption<bool>("--all");
var refresh = context.ParseResult.ValueForOption<bool>("--refresh");

var defaultWorkspaceId = context.ConfigValues.DefaultWorkspaceId;

var workspaces = context.ConfigValues.Workspaces.ToList();
if (refresh)
{
var response = await context.Client.Workspaces.ListAsync(cancellationToken: cancellationToken);
response.EnsureSuccess();
workspaces = response.Resource!.Select(w => new ConfigValuesWorkspace(w)).ToList();
context.ConfigValues.Workspaces = workspaces; // update them so that they are saved

// update default workspace
if (workspaces.Count > 0)
{
if (defaultWorkspaceId is not null)
{
var workspace = context.ConfigValues.GetWorkspace(defaultWorkspaceId);
if (workspace is null)
{
context.Logger.LogInformation("Default workspace '{DefaultWorkspaceId}' not found. Resetting to null.", defaultWorkspaceId);
defaultWorkspaceId = context.ConfigValues.DefaultWorkspaceId = null;
}
}
}
}

workspaces = all ? workspaces : workspaces.Where(w => !string.Equals(w.Status, "terminated", StringComparison.OrdinalIgnoreCase)).ToList();

var table = new Table().AddColumn("Name")
.AddColumn("Id")
.AddColumn("Status")
.AddColumn(new TableColumn("Default").Centered());

foreach (var workspace in workspaces)
{
var isDefault = string.Equals(workspace.Id, defaultWorkspaceId, StringComparison.OrdinalIgnoreCase);
table.AddRow(new Markup(workspace.Name),
new Markup(workspace.Id),
new Markup(workspace.Status),
new Markup(isDefault ? SpectreFormatter.ColouredGreen("✔ YES") : ""));
}

AnsiConsole.Write(table);

return 0;
}
}

internal class WorkspacesShowCommand : FaluCliCommand
{
public WorkspacesShowCommand() : base("show", " Get the details of a workspace.")
{
this.AddOption<string>(aliases: ["--name", "-n"],
description: "Name or ID of workspace.",
configure: o => o.Required = true);
}

public override Task<int> ExecuteAsync(CliCommandExecutionContext context, CancellationToken cancellationToken)
{
var name = context.ParseResult.ValueForOption<string>("--name")!;

var workspace = context.ConfigValues.GetRequiredWorkspace(name);

var table = new Table().AddColumn("Name")
.AddColumn("Id")
.AddColumn("Status")
.AddColumn(new TableColumn("Default").Centered());

var isDefault = string.Equals(workspace.Id, context.ConfigValues.DefaultWorkspaceId, StringComparison.OrdinalIgnoreCase);
table.AddRow(new Markup(workspace.Name),
new Markup(workspace.Id),
new Markup(workspace.Status),
new Markup(isDefault ? SpectreFormatter.ColouredGreen("✔ YES") : ""));

AnsiConsole.Write(table);

return Task.FromResult(0);
}
}
46 changes: 46 additions & 0 deletions src/FaluCli/Config/ConfigValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal sealed class ConfigValues(JsonObject inner) : AbstractConfigValues(inne
private const string KeyDefaultWorkspaceId = "default_workspace_id";
private const string KeyDefaultLiveMode = "default_live_mode";
private const string KeyAuthentication = "authentication";
private const string KeyWorkspaces = "workspaces";

public bool NoTelemetry { get => GetPrimitiveValue(KeyNoTelemetry, false); set => SetValue(KeyNoTelemetry, value); }
public bool NoUpdates { get => GetPrimitiveValue(KeyNoUpdates, false); set => SetValue(KeyNoUpdates, value); }
Expand All @@ -33,6 +34,20 @@ public ConfigValuesAuthenticationTokens? Authentication
set => SetValue(KeyAuthentication, value);
}

public List<ConfigValuesWorkspace> Workspaces
{
get => (GetArray(KeyWorkspaces) ?? []).Select(n => (ConfigValuesWorkspace)n!.AsObject()).ToList();
set => SetValue(KeyWorkspaces, value is not null ? new JsonArray(value.Select(w => (JsonObject)w).ToArray()) : default);
}

public ConfigValuesWorkspace? GetWorkspace(string idOrName)
{
var workspaces = Workspaces.ToArray();
return workspaces.FirstOrDefault(w => string.Equals(w.Id, idOrName, StringComparison.OrdinalIgnoreCase) || string.Equals(w.Name, idOrName, StringComparison.OrdinalIgnoreCase));
}

public ConfigValuesWorkspace GetRequiredWorkspace(string idOrName) => GetWorkspace(idOrName) ?? throw new FaluException($"Workspace '{idOrName}' not found. Check the spelling and casing and try again.");

public void RemoveUnknownKeys()
{
var keys = Inner.Select(n => n.Key).Except([
Expand All @@ -44,6 +59,7 @@ public void RemoveUnknownKeys()
KeyDefaultWorkspaceId,
KeyDefaultLiveMode,
KeyAuthentication,
KeyWorkspaces,
]).ToArray();

foreach (var key in keys) Inner.Remove(key);
Expand Down Expand Up @@ -85,6 +101,32 @@ public ConfigValuesAuthenticationTokens(OidcTokenResponse response) : base([])
public static implicit operator ConfigValuesAuthenticationTokens?(JsonObject? inner) => inner is null ? null : new(inner);
}

internal sealed class ConfigValuesWorkspace : AbstractConfigValues
{
private const string KeyId = "id";
private const string KeyName = "name";
private const string KeyStatus = "status";

public ConfigValuesWorkspace(JsonObject inner) : base(inner) { }

public ConfigValuesWorkspace(Client.Workspaces.Workspace workspace) : base([])
{
Id = workspace.Id!;
Name = workspace.Name!;
Status = workspace.Status!;
}

public string Id { get => GetRequiredValue<string>(KeyId); set => SetValue(KeyId, value); }
public string Name { get => GetRequiredValue<string>(KeyName); set => SetValue(KeyName, value); }
public string Status { get => GetRequiredValue<string>(KeyStatus); set => SetValue(KeyStatus, value); }

[return: NotNullIfNotNull(nameof(workspace))]
public static implicit operator JsonObject?(ConfigValuesWorkspace? workspace) => workspace?.Inner;

[return: NotNullIfNotNull(nameof(inner))]
public static implicit operator ConfigValuesWorkspace?(JsonObject? inner) => inner is null ? null : new(inner);
}

internal abstract class AbstractConfigValues(JsonObject inner)
{
protected JsonObject Inner { get; } = inner;
Expand All @@ -95,7 +137,11 @@ internal abstract class AbstractConfigValues(JsonObject inner)
protected T GetPrimitiveValue<T>(string key, T defaultValue) where T : struct => GetPrimitiveValue<T>(key) ?? defaultValue;
protected T GetValue<T>(string key, T defaultValue) => GetValue<T>(key) ?? defaultValue;

protected T GetRequiredPrimitiveValue<T>(string key) where T : struct => GetPrimitiveValue<T>(key) ?? throw new InvalidOperationException($"The required value '{key}' is missing.");
protected T GetRequiredValue<T>(string key) => GetValue<T>(key) ?? throw new InvalidOperationException($"The required value '{key}' is missing.");

protected JsonObject? GetObject(string key) => Inner.TryGetPropertyValue(key, out var node) && node is JsonObject jo ? jo : null;
protected JsonArray? GetArray(string key) => Inner.TryGetPropertyValue(key, out var node) && node is JsonArray ja ? ja : null;

protected void SetValue(string key, JsonNode? node)
{
Expand Down
2 changes: 2 additions & 0 deletions src/FaluCli/FaluCliJsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

namespace Falu;

[JsonSerializable(typeof(List<Client.Workspaces.Workspace>))]

[JsonSerializable(typeof(Events.WebhookEvent))]
[JsonSerializable(typeof(Client.Events.EventDeliveryRetry))]
[JsonSerializable(typeof(Client.Events.WebhookDeliveryAttempt))]
Expand Down
2 changes: 2 additions & 0 deletions src/FaluCli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Azure.Monitor.OpenTelemetry.Exporter;
using Falu;
using Falu.Client;
using Falu.Commands;
using Falu.Commands.Config;
using Falu.Commands.Events;
using Falu.Commands.Login;
Expand All @@ -18,6 +19,7 @@
{
new LoginCommand(),
new LogoutCommand(),
new WorkspacesCommand(),

new EventsCommand(),

Expand Down
2 changes: 1 addition & 1 deletion src/FaluCli/WorkspacedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public WorkspacedCommand(string name, string? description = null) : base(name, d

this.AddOption(aliases: ["--workspace"],
description: Res.OptionDescriptionWorkspace,
format: Constants.WorkspaceIdFormat);
format: Constants.WorkspaceIdFormat); // TODO: allow name here and change the validation to use configValues hence update readers of the option

// without this the nullable type, the option is not found because we have not migrated to the new bindings
this.AddOption<bool?>(aliases: ["--live"],
Expand Down

0 comments on commit c8c7cfd

Please sign in to comment.