-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProgram.cs
More file actions
174 lines (135 loc) · 6.67 KB
/
Copy pathProgram.cs
File metadata and controls
174 lines (135 loc) · 6.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
using PlanApi;
using PlanApi.Indexing;
using PlanApi.Messaging;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Scalar.AspNetCore;
using StackExchange.Redis;
using System.Text.Json.Nodes;
using Json.Schema;
using System.Linq;
using System.Text.Json;
// Top-level statements: this file IS the program (no Main method needed).
var builder = WebApplication.CreateBuilder(args);
// builder.Services is the DI container. Frozen after Build().
builder.Services.AddOpenApi();
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(
builder.Configuration.GetConnectionString("Redis")
?? throw new InvalidOperationException("ConnectionStrings:Redis is not configured")));
builder.Services.AddSingleton(JsonSchema.FromFile(
Path.Combine(builder.Environment.ContentRootPath, "schema.json")));
builder.Services.AddSingleton<IPlanRepository, RedisRepository>();
// One RabbitMQ connection for the process, not one per request. Registered three ways so that
// the interface and the startup hook resolve to the *same* instance rather than two publishers.
builder.Services.AddSingleton<RabbitPublisher>();
builder.Services.AddSingleton<IPlanPublisher>(sp => sp.GetRequiredService<RabbitPublisher>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<RabbitPublisher>());
// The consumer side: reads the queue and writes the derived documents into Elasticsearch.
builder.Services.AddSingleton<IPlanIndexer, PlanIndexer>();
builder.Services.AddHostedService<IndexerService>();
// Resource-server auth: validate Google-issued ID tokens, never mint them.
// Authority triggers OIDC discovery + JWKS fetch, so signing keys auto-rotate.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://accounts.google.com";
options.Audience = builder.Configuration["Google:ClientId"]
?? throw new InvalidOperationException("Google:ClientId is not configured");
});
builder.Services.AddAuthorization();
var app = builder.Build();
_ = app.Services.GetRequiredService<IConnectionMultiplexer>();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseAuthentication();
app.UseAuthorization();
// All plan endpoints require a valid Bearer token; unauthenticated -> 401.
var plan = app.MapGroup("/v1/plan").RequireAuthorization();
plan.MapPost("", async (JsonNode body, IPlanRepository repo, JsonSchema schema, IPlanPublisher publisher, HttpResponse response) =>
{
var result = schema.Evaluate(body.Deserialize<JsonElement>(), new EvaluationOptions { OutputFormat = OutputFormat.List });
if (!result.IsValid)
{
var errors = result.Details
.Where(d => d.Errors is not null && d.Errors.Count > 0)
.SelectMany(d => d.Errors!.Select(e => new
{
path = d.InstanceLocation.ToString(),
keyword = e.Key,
message = e.Value
}))
.ToList();
return Results.BadRequest(new { errors });
}
var objectId = body["objectId"]!.GetValue<string>();
if (await repo.ExistsAsync(objectId))
return Results.Conflict(new { error = $"plan '{objectId}' already exists" });
await repo.SaveFlattenedAsync(PlanFlattener.Decompose(body));
// Hook: Redis has committed, so tell the indexer. Never throws — see RabbitPublisher.
await publisher.PublishAsync(PlanMessage.Create(objectId, body.AsObject()));
response.Headers.ETag = ETag.Compute(body);
return Results.Created($"/v1/plan/{objectId}", null);
});
plan.MapGet("/{objectId}", async (string objectId, IPlanRepository repo, HttpRequest request, HttpResponse response) =>
{
var plan = await repo.GetAsync(objectId);
if (plan is null) return Results.NotFound();
var etag = ETag.Compute(plan);
response.Headers.ETag = etag;
if (request.Headers.IfNoneMatch.Contains(etag))
return Results.StatusCode(StatusCodes.Status304NotModified);
return Results.Ok(plan);
});
plan.MapDelete("/{objectId}", async (string objectId, IPlanRepository repo, IPlanPublisher publisher) =>
{
// Read the tree BEFORE deleting it: the descendant ids only exist inside it, and
// repo.DeleteAsync is about to remove every key we would need to discover them.
var tree = await repo.GetAsync(objectId);
var deleted = await repo.DeleteAsync(objectId);
if (!deleted) return Results.NotFound();
if (tree is not null)
await publisher.PublishAsync(PlanMessage.Delete(objectId, EsFlattener.CollectIds(tree)));
return Results.NoContent();
});
plan.MapPatch("/{objectId}", async (string objectId, JsonNode body, IPlanRepository repo, JsonSchema schema, IPlanPublisher publisher, HttpRequest request, HttpResponse response) =>
{
// objectId is the resource identity: reject any attempt to change it via the body.
if (body["objectId"] is JsonNode bodyId && bodyId.ToString() != objectId)
return Results.BadRequest(new { error = "objectId is immutable and cannot be changed via PATCH" });
var stored = await repo.GetAsync(objectId);
if (stored is null) return Results.NotFound();
// Conditional write: If-Match is mandatory ("update if not changed").
var ifMatch = request.Headers.IfMatch;
if (ifMatch.Count == 0)
return Results.StatusCode(StatusCodes.Status428PreconditionRequired);
var currentETag = ETag.Compute(stored);
if (!ifMatch.Contains("*") && !ifMatch.Contains(currentETag))
return Results.StatusCode(StatusCodes.Status412PreconditionFailed);
// Merge the partial body, then validate the *result* (the body itself is partial).
var merged = PlanMerger.Merge(stored, body.AsObject());
var result = schema.Evaluate(merged.Deserialize<JsonElement>(), new EvaluationOptions { OutputFormat = OutputFormat.List });
if (!result.IsValid)
{
var errors = result.Details
.Where(d => d.Errors is not null && d.Errors.Count > 0)
.SelectMany(d => d.Errors!.Select(e => new
{
path = d.InstanceLocation.ToString(),
keyword = e.Key,
message = e.Value
}))
.ToList();
return Results.BadRequest(new { errors });
}
await repo.SaveFlattenedAsync(PlanFlattener.Decompose(merged));
// Hook: publish the *merged* plan, not the partial patch body. The consumer re-flattens
// the whole tree and upserts every document by id, so the index cannot drift from Redis.
await publisher.PublishAsync(PlanMessage.Update(objectId, merged));
response.Headers.ETag = ETag.Compute(merged);
return Results.Ok(merged);
});
app.UseHttpsRedirection();
app.Run();