feat(api): workflows CRUD + activate/deactivate flag

This commit is contained in:
Claude
2026-08-28 12:18:58 +00:00
parent 0f52730baf
commit 57db5a53db
6 changed files with 228 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
using System.Text.Json;
namespace VolcanicCore.Api.Contracts;
public sealed record CreateWorkflowRequest(string Name, JsonElement Graph);
public sealed record UpdateWorkflowRequest(string Name, JsonElement Graph);
public sealed record WorkflowResponse(string Id, string Name, bool Active,
JsonElement Graph, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Endpoints;
public static class WorkflowsEndpoints
{
public static void MapWorkflowsEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/workflows").RequireAuthorization();
g.MapGet("", (WorkflowRepository repo) =>
Results.Ok(repo.List().Select(ToDto)));
g.MapGet("/{id}", (string id, WorkflowRepository repo) =>
{
var w = repo.Get(id);
return w is null ? Results.NotFound() : Results.Ok(ToDto(w));
});
g.MapPost("", (CreateWorkflowRequest req, WorkflowRepository repo) =>
{
var id = repo.Create(req.Name, req.Graph.GetRawText());
var w = repo.Get(id)!;
return Results.Created($"/api/workflows/{id}", ToDto(w));
});
g.MapPut("/{id}", (string id, UpdateWorkflowRequest req, WorkflowRepository repo) =>
repo.Update(id, req.Name, req.Graph.GetRawText())
? Results.NoContent() : Results.NotFound());
g.MapPost("/{id}/activate", (string id, WorkflowRepository repo) =>
repo.SetActive(id, true) ? Results.NoContent() : Results.NotFound());
g.MapPost("/{id}/deactivate", (string id, WorkflowRepository repo) =>
repo.SetActive(id, false) ? Results.NoContent() : Results.NotFound());
g.MapDelete("/{id}", (string id, WorkflowRepository repo) =>
repo.Delete(id) ? Results.NoContent() : Results.NotFound());
}
private static WorkflowResponse ToDto(WorkflowRow w)
{
using var doc = JsonDocument.Parse(w.GraphJson);
return new WorkflowResponse(w.Id, w.Name, w.Active, doc.RootElement.Clone(),
w.CreatedAt, w.UpdatedAt);
}
}

View File

@@ -40,9 +40,11 @@ builder.Services.AddSingleton<Migrator>(_ => new Migrator(new IMigration[]
{
new Migration001Initial(),
new Migration002Connections(),
new Migration003Workflows(),
}));
builder.Services.AddSingleton<PasswordService>();
builder.Services.AddSingleton<ConnectionRepository>();
builder.Services.AddSingleton<WorkflowRepository>();
builder.Services.AddSingleton<IConnectionSecretPolicy, HardcodedSecretPolicy>();
builder.Services
@@ -101,6 +103,7 @@ app.MapSessionEndpoints();
app.MapNodeTypesEndpoints();
app.MapConnectionProvidersEndpoints();
app.MapConnectionsEndpoints();
app.MapWorkflowsEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { ok = true }));
app.Run();

View File

@@ -0,0 +1,23 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Migrations;
public sealed class Migration003Workflows : IMigration
{
public int Version => 3;
public void Apply(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE workflows (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
graph_json TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
";
cmd.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Data.Sqlite;
namespace VolcanicCore.Persistence.Repositories;
public sealed record WorkflowRow(string Id, string Name, string GraphJson, bool Active,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
public sealed class WorkflowRepository
{
private readonly SqliteConnectionFactory _factory;
public WorkflowRepository(SqliteConnectionFactory f) => _factory = f;
public string Create(string name, string graphJson)
{
var id = Guid.NewGuid().ToString("N");
var now = DateTimeOffset.UtcNow.ToString("o");
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT INTO workflows (id, name, graph_json, active, created_at, updated_at)
VALUES ($id, $name, $graph, 0, $now, $now);
";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$graph", graphJson);
cmd.Parameters.AddWithValue("$now", now);
cmd.ExecuteNonQuery();
return id;
}
public WorkflowRow? Get(string id)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, name, graph_json, active, created_at, updated_at
FROM workflows WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
using var r = cmd.ExecuteReader();
return r.Read() ? Materialize(r) : null;
}
public IReadOnlyList<WorkflowRow> List()
{
var rows = new List<WorkflowRow>();
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT id, name, graph_json, active, created_at, updated_at
FROM workflows ORDER BY created_at DESC;";
using var r = cmd.ExecuteReader();
while (r.Read()) rows.Add(Materialize(r));
return rows;
}
public bool Update(string id, string name, string graphJson)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"UPDATE workflows SET name = $name, graph_json = $graph,
updated_at = $now WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$name", name);
cmd.Parameters.AddWithValue("$graph", graphJson);
cmd.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("o"));
return cmd.ExecuteNonQuery() == 1;
}
public bool SetActive(string id, bool active)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"UPDATE workflows SET active = $a, updated_at = $now WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$a", active ? 1 : 0);
cmd.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToString("o"));
return cmd.ExecuteNonQuery() == 1;
}
public bool Delete(string id)
{
using var conn = _factory.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"DELETE FROM workflows WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", id);
return cmd.ExecuteNonQuery() == 1;
}
private static WorkflowRow Materialize(SqliteDataReader r) => new(
r.GetString(0), r.GetString(1), r.GetString(2), r.GetInt64(3) != 0,
DateTimeOffset.Parse(r.GetString(4)), DateTimeOffset.Parse(r.GetString(5)));
}

View File

@@ -0,0 +1,55 @@
using System.Net;
using System.Net.Http.Json;
namespace VolcanicCore.Tests;
public class WorkflowsEndpointsTests : IClassFixture<WebFactory>
{
private readonly WebFactory _factory;
public WorkflowsEndpointsTests(WebFactory f) => _factory = f;
private async Task<HttpClient> LoggedIn()
{
var c = _factory.CreateClient(new() { HandleCookies = true, AllowAutoRedirect = false });
await c.PostAsJsonAsync("/api/session", new { password = _factory.AdminPassword });
return c;
}
private static object EmptyGraph() => new { nodes = new object[0], edges = new object[0] };
[Fact]
public async Task Full_crud_round_trip()
{
var c = await LoggedIn();
var create = await c.PostAsJsonAsync("/api/workflows", new { name = "hello", graph = EmptyGraph() });
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var created = await create.Content.ReadFromJsonAsync<WorkflowDto>();
Assert.NotNull(created);
Assert.False(created!.Active);
var got = await c.GetFromJsonAsync<WorkflowDto>($"/api/workflows/{created.Id}");
Assert.Equal("hello", got!.Name);
var upd = await c.PutAsJsonAsync($"/api/workflows/{created.Id}",
new { name = "renamed", graph = EmptyGraph() });
Assert.Equal(HttpStatusCode.NoContent, upd.StatusCode);
var toggle = await c.PostAsync($"/api/workflows/{created.Id}/activate", null);
Assert.Equal(HttpStatusCode.NoContent, toggle.StatusCode);
var after = await c.GetFromJsonAsync<WorkflowDto>($"/api/workflows/{created.Id}");
Assert.True(after!.Active);
Assert.Equal("renamed", after.Name);
var deact = await c.PostAsync($"/api/workflows/{created.Id}/deactivate", null);
Assert.Equal(HttpStatusCode.NoContent, deact.StatusCode);
var del = await c.DeleteAsync($"/api/workflows/{created.Id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var missing = await c.GetAsync($"/api/workflows/{created.Id}");
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
}
private sealed record WorkflowDto(string Id, string Name, bool Active,
System.Text.Json.JsonElement Graph, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
}