Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@
(build/SupervisedTests.cs): class-partitioned parallel workers, per-lane environments,
and honest retry reporting. Referenced ONLY by the build project. -->
<PackageVersion Include="Bobcat.Supervisor" Version="0.8.0" />
<PackageVersion Include="JasperFx" Version="2.55.0" />
<PackageVersion Include="JasperFx.Events" Version="2.55.0" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.55.0" />
<PackageVersion Include="JasperFx" Version="2.56.0" />
<PackageVersion Include="JasperFx.Events" Version="2.56.0" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.56.0" />
<!-- RuntimeCompiler is on its own 5.x line (the Roslyn compiler package) — not the 2.1.x
family; it stays at 5.0.0. -->
<PackageVersion Include="JasperFx.RuntimeCompiler" Version="5.0.0" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.55.0" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.56.0" />
<PackageVersion Include="Lamar.Microsoft.DependencyInjection" Version="16.0.0" />
<PackageVersion Include="Marten" Version="9.23.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.3" />
Expand Down
28 changes: 28 additions & 0 deletions docs/guide/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,34 @@ dotnet run -- event-model --json ./docs/orders.json
dotnet run -- event-model --json ./out.json --name Orders
```

### Publishing to a monitor

`--url` PUTs the assembled descriptor to a monitor instead of writing it to a file, which collapses the
design-time loop to a single command:

```bash
dotnet watch run -- event-model --url http://localhost:5525
```

The two flags compose. `--url` on its own publishes and writes nothing; add `--json` and you get both, with
the file and the request body byte-for-byte identical:

```bash
dotnet run -- event-model --url http://localhost:5525 # publish only
dotnet run -- event-model --url http://localhost:5525 --json ./out.json # publish and write
```

Wolverine takes **no reference on the monitor** — this is an HTTP PUT to whatever URL you name, so anything
that accepts the descriptor works. A monitor that is down fails with a one-line message and a non-zero exit
rather than a stack trace, because under `dotnet watch` a console you have not started yet is the ordinary
case and must not look like a crash.

::: tip
Note that the rebuild has to come from `dotnet watch`, not from a `--watch` flag on the command. The command's
process already has your assembly loaded, so an internal loop would re-serialize the same chains forever and
never see an edit — only a fresh process picks up recompiled handlers.
:::

The host is built but **never started**: the handler graph is compiled the same way
`wolverine-diagnostics describe-handlers` does it, so no transport is opened, no database is touched, and no
runtime compiler is needed — a `TypeLoadMode.Dynamic` application without `WolverineFx.RuntimeCompilation`
Expand Down
9 changes: 9 additions & 0 deletions src/Http/Wolverine.Http/Diagnostics/HttpEventModelSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ public HttpEventModelSource(WolverineHttpOptions options, WolverineOptions wolve

public Uri Subject { get; } = new($"{WolverineEventModelSource.Scheme}://wolverine-http");

/// <summary>
/// GH-4147/GH-4152. Roles here are derived off compiled <see cref="HttpChain" />s, so this source
/// sits on the same <see cref="EventModelProvenance.Derived" /> rung as Wolverine core's
/// (jasperfx#703), which is what replaced the <c>services.Insert(0, ...)</c> registration hack.
/// Two sources on the same rung union rather than clobber, so this and
/// <see cref="WolverineEventModelSource" /> still both contribute to the one model per service.
/// </summary>
public EventModelProvenance Provenance => EventModelProvenance.Derived;

public Task<EventModelDescriptor?> TryCreateAsync(IServiceProvider services, CancellationToken token)
{
var graph = _options.Endpoints;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,12 @@ public static IServiceCollection AddWolverineHttp(this IServiceCollection servic
// shipped the richer reader yet.
services.AddSingleton<IHttpGraphUsageSource, HttpGraphUsageSource>();

// GH-3988 — the Wolverine.HTTP-derived Event Model source. Inserted at the front, like Wolverine
// core's, so an overlay registered earlier cannot overwrite a derived role on merge.
// GH-3988 — the Wolverine.HTTP-derived Event Model source. GH-4152: appended rather than inserted
// at the front, now that the source declares EventModelProvenance.Derived and precedence is on the
// ladder instead of registration order.
if (services.All(x => x.ImplementationType != typeof(HttpEventModelSource)))
{
services.Insert(0, ServiceDescriptor.Singleton<IEventModelDefinitionSource, HttpEventModelSource>());
services.AddSingleton<IEventModelDefinitionSource, HttpEventModelSource>();
}

// Registered unconditionally — harmless when no versioned endpoint uses it.
Expand Down
120 changes: 120 additions & 0 deletions src/Testing/CoreTests/Acceptance/event_model_provenance_ladder_4147.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using JasperFx.Descriptors;
using JasperFx.Events.EventModeling;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Wolverine;
using Wolverine.Configuration.EventModeling;
using Xunit;

namespace CoreTests.Acceptance.EventModel4147;

// GH-4147 / GH-4152. The acceptance criteria for stamping provenance are precedence claims, so they are
// tested as precedence: "every role the Wolverine source emits is attributed as derived; a
// runtime-observed source can override a derived role; an overlay cannot."
//
// ⚠️ Worth knowing why this file exists at all rather than leaning on the GH-3988 overlay fixture. The
// public overlay API (EventModelSliceBuilder) can only express TriggeredBy / InDomain /
// LinksToSpecification / Hotspot -- annotations, none of which are factual roles. So an overlay
// *cannot* collide with a derived role, and that fixture passes with or without the Derived stamp: it
// never exercises the ladder. Only a custom IEventModelDefinitionSource can claim a role Wolverine also
// claims, which is what these stubs do.
public class event_model_provenance_ladder_4147
{
private static async Task<EventModelDescriptor> assembleWithAsync(params IEventModelDefinitionSource[] rivals)
{
using var host = await Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
// Registered BEFORE UseWolverine() so registration order favours the rival. Precedence
// must come off the ladder now, not off the old services.Insert(0, ...) hack.
foreach (var rival in rivals)
{
services.AddSingleton(rival);
}
})
.UseWolverine(opts =>
{
opts.ServiceName = "provenance-4147";
opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(RecordPaymentHandler));
}).StartAsync();

var model = await WolverineEventModelExport.AssembleAsync(host.Services,
token: TestContext.Current.CancellationToken);

await host.StopAsync();
return model;
}

[Fact]
public async Task an_observed_source_overrides_a_derived_role()
{
var model = await assembleWithAsync(
new StubSource(EventModelProvenance.Observed, typeof(ObservedInProduction)));

// The inversion #4147 asks for, and it is deliberate: what the fleet actually emits beats what
// the code says it should emit.
model.Slices.Single(x => x.Name == nameof(RecordPayment))
.PublishedMessages.Select(x => x.Name)
.ShouldBe([nameof(ObservedInProduction)]);
}

[Fact]
public async Task a_declared_source_cannot_override_a_derived_role()
{
var model = await assembleWithAsync(
new StubSource(EventModelProvenance.Declared, typeof(MerelyDeclared)));

// Registered first, and still loses -- this is exactly what services.Insert(0, ...) used to buy
// and what the Derived stamp buys now.
model.Slices.Single(x => x.Name == nameof(RecordPayment))
.PublishedMessages.Select(x => x.Name)
.ShouldBe([nameof(PaymentRecorded)]);
}

[Fact]
public async Task the_derived_role_is_what_wolverine_actually_compiled()
{
var model = await assembleWithAsync();

model.Slices.Single(x => x.Name == nameof(RecordPayment))
.PublishedMessages.Select(x => x.Name)
.ShouldBe([nameof(PaymentRecorded)]);
}

/// <summary>
/// A rival source claiming the same slice's PublishedMessages at a chosen rung.
/// </summary>
private sealed class StubSource(EventModelProvenance provenance, Type published) : IEventModelDefinitionSource
{
public Uri Subject { get; } = new("event-model://stub-4147");

public EventModelProvenance Provenance => provenance;

public Task<EventModelDescriptor?> TryCreateAsync(IServiceProvider services, CancellationToken token)
{
var slice = new EventModelSliceDescriptor(
nameof(RecordPayment), null, null, null, null,
Array.Empty<TypeDescriptor>(), Array.Empty<TypeDescriptor>(), Array.Empty<TypeDescriptor>())
{
PublishedMessages = [TypeDescriptor.For(published)]
};

return Task.FromResult<EventModelDescriptor?>(
new EventModelDescriptor("provenance-4147", [slice]));
}
}
}

public record RecordPayment(string Id);

public record PaymentRecorded(string Id);

public record ObservedInProduction(string Id);

public record MerelyDeclared(string Id);

public class RecordPaymentHandler
{
public PaymentRecorded Handle(RecordPayment command) => new(command.Id);
}
168 changes: 168 additions & 0 deletions src/Testing/CoreTests/Acceptance/event_model_publish_url_4146.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System.Net;
using System.Text;
using JasperFx.Events.EventModeling;
using Shouldly;
using Wolverine.Configuration.EventModeling;
using Xunit;

namespace CoreTests.Acceptance;

// GH-4146: `event-model --url <monitor>` PUTs the assembled descriptor instead of (or as well as) writing
// the file, so the design-time loop collapses to `dotnet watch run -- event-model --url ...`.
public class event_model_publish_url_4146
{
// ---- flag composition (no host, no socket) ----

[Fact]
public void neither_flag_still_writes_the_default_file()
{
new EventModelInput().ResolveJsonPath().ShouldBe(EventModelInput.DefaultJsonFile);
}

[Fact]
public void json_alone_writes_the_named_file()
{
new EventModelInput { JsonFlag = "custom.json" }.ResolveJsonPath().ShouldBe("custom.json");
}

[Fact]
public void url_alone_publishes_without_dropping_a_file()
{
// The point of the default moving off "event-model.json": running the watch loop should not
// litter the application directory with a file nobody asked for.
new EventModelInput { UrlFlag = "http://localhost:5525" }.ResolveJsonPath().ShouldBeNull();
}

[Fact]
public void json_and_url_compose()
{
new EventModelInput { JsonFlag = "custom.json", UrlFlag = "http://localhost:5525" }
.ResolveJsonPath().ShouldBe("custom.json");
}

// ---- the PUT itself, against a real socket ----

[Fact]
public async Task publishes_the_same_json_the_file_form_writes()
{
await using var monitor = new StubMonitor();

var model = new EventModelDescriptor("PublishMe", []);
var succeeded = await invokePublishAsync(model, monitor.Url);

succeeded.ShouldBeTrue();
monitor.Method.ShouldBe("PUT");
monitor.ContentType.ShouldStartWith("application/json");

// Byte-for-byte the file form's payload, so a monitor cannot tell the two apart.
monitor.Body.ShouldBe(WolverineEventModelExport.ToJson(model));

// ...and it round-trips back through the descriptor.
WolverineEventModelExport.FromJson(monitor.Body!)!.Name.ShouldBe("PublishMe");
}

[Fact]
public async Task a_monitor_that_rejects_the_model_fails_rather_than_reporting_success()
{
await using var monitor = new StubMonitor(HttpStatusCode.BadRequest, "not today");

var succeeded = await invokePublishAsync(new EventModelDescriptor("Rejected", []), monitor.Url);

succeeded.ShouldBeFalse();
}

[Fact]
public async Task a_monitor_that_is_down_fails_with_a_message_not_a_stack_trace()
{
// Nothing is listening here. Under `dotnet watch` a monitor that has not been started yet is the
// ordinary case, so this has to be a sentence and a non-zero exit -- never an unhandled exception.
var output = new StringWriter();
var original = Console.Out;
Console.SetOut(output);

bool succeeded;
try
{
succeeded = await invokePublishAsync(new EventModelDescriptor("Nobody", []),
new Uri($"http://127.0.0.1:{unusedPort()}"));
}
finally
{
Console.SetOut(original);
}

succeeded.ShouldBeFalse();
output.ToString().ShouldContain("Could not reach the monitor");
}

private static Task<bool> invokePublishAsync(EventModelDescriptor model, Uri monitor)
{
var method = typeof(EventModelCommand)
.GetMethod("publishAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;

return (Task<bool>)method.Invoke(null, [model, monitor, "the Event Model"])!;
}

private static int unusedPort()
{
var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}

/// <summary>Minimal HttpListener standing in for a Bobcat/CritterWatch console.</summary>
private sealed class StubMonitor : IAsyncDisposable
{
private readonly HttpListener _listener = new();
private readonly Task _serving;

public StubMonitor(HttpStatusCode status = HttpStatusCode.NoContent, string? responseBody = null)
{
var port = unusedPort();
Url = new Uri($"http://127.0.0.1:{port}/event-model");
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
_listener.Start();

_serving = Task.Run(async () =>
{
var context = await _listener.GetContextAsync();
Method = context.Request.HttpMethod;
ContentType = context.Request.ContentType;
using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8))
{
Body = await reader.ReadToEndAsync();
}

context.Response.StatusCode = (int)status;
if (responseBody != null)
{
var bytes = Encoding.UTF8.GetBytes(responseBody);
await context.Response.OutputStream.WriteAsync(bytes);
}

context.Response.Close();
});
}

public Uri Url { get; }
public string? Method { get; private set; }
public string? ContentType { get; private set; }
public string? Body { get; private set; }

public async ValueTask DisposeAsync()
{
try
{
await _serving.WaitAsync(TimeSpan.FromSeconds(5));
}
catch
{
// the negative tests never send a request; nothing to drain
}

_listener.Close();
}
}
}
Loading
Loading