|
| 1 | +using System.Text; |
| 2 | +using System.Text.Json; |
| 3 | +using System.Text.Json.Serialization.Metadata; |
1 | 4 | using Flink.JobGateway.Services; |
| 5 | +using Microsoft.AspNetCore.Mvc; |
| 6 | +using Microsoft.AspNetCore.Mvc.Filters; |
2 | 7 | using Microsoft.OpenApi.Models; |
3 | 8 |
|
4 | 9 | var builder = WebApplication.CreateBuilder(args); |
5 | 10 |
|
6 | | -// Add services to the container |
7 | | -builder.Services.AddControllers(); |
| 11 | +// Add controllers with JSON + ModelState logging |
| 12 | +builder.Services |
| 13 | + .AddControllers(options => |
| 14 | + { |
| 15 | + options.Filters.Add<ModelStateLoggingFilter>(); |
| 16 | + }) |
| 17 | + .AddJsonOptions(o => |
| 18 | + { |
| 19 | + o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; |
| 20 | + o.JsonSerializerOptions.WriteIndented = false; |
| 21 | + // Insert default resolver so interface polymorphic attributes are honored consistently |
| 22 | + o.JsonSerializerOptions.TypeInfoResolverChain.Insert(0, new DefaultJsonTypeInfoResolver()); |
| 23 | + }); |
| 24 | + |
8 | 25 | builder.Services.AddEndpointsApiExplorer(); |
9 | 26 | builder.Services.AddSwaggerGen(c => |
10 | 27 | { |
11 | | - c.SwaggerDoc("v1", new OpenApiInfo |
12 | | - { |
| 28 | + c.SwaggerDoc("v1", new OpenApiInfo |
| 29 | + { |
13 | 30 | Title = "Flink Job Gateway API", |
14 | 31 | Version = "v1", |
15 | 32 | Description = "REST API for submitting and managing Apache Flink jobs from .NET applications" |
16 | 33 | }); |
17 | 34 | }); |
18 | 35 |
|
19 | | -// Add API versioning |
| 36 | +// API versioning |
20 | 37 | builder.Services.AddApiVersioning(options => |
21 | 38 | { |
22 | 39 | options.AssumeDefaultVersionWhenUnspecified = true; |
|
29 | 46 | options.SubstituteApiVersionInUrl = true; |
30 | 47 | }); |
31 | 48 |
|
32 | | -// Register services |
| 49 | +// Services |
33 | 50 | builder.Services.AddHttpClient<IFlinkJobManager, FlinkJobManager>(); |
34 | 51 |
|
35 | | -// Configure logging |
| 52 | +// Logging |
36 | 53 | builder.Services.AddLogging(loggingBuilder => |
37 | 54 | { |
38 | 55 | loggingBuilder.AddConsole(); |
|
41 | 58 |
|
42 | 59 | var app = builder.Build(); |
43 | 60 |
|
44 | | -// Configure the HTTP request pipeline |
| 61 | +// Diagnostic middleware: capture raw body + 400 responses for /api/v1/jobs/submit |
| 62 | +app.Use(async (ctx, next) => |
| 63 | +{ |
| 64 | + var isSubmit = ctx.Request.Path.Equals("/api/v1/jobs/submit", StringComparison.OrdinalIgnoreCase); |
| 65 | + if (isSubmit) |
| 66 | + { |
| 67 | + try |
| 68 | + { |
| 69 | + ctx.Request.EnableBuffering(); |
| 70 | + using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true); |
| 71 | + var raw = await reader.ReadToEndAsync(); |
| 72 | + ctx.Request.Body.Position = 0; |
| 73 | + var log = ctx.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JobSubmitRawBody"); |
| 74 | + log.LogInformation("Raw job submission body: {Body}", raw); |
| 75 | + } |
| 76 | + catch (Exception ex) |
| 77 | + { |
| 78 | + var log = ctx.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JobSubmitRawBody"); |
| 79 | + log.LogWarning(ex, "Failed to read raw submission body."); |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + var originalBody = ctx.Response.Body; |
| 84 | + using var mem = new MemoryStream(); |
| 85 | + ctx.Response.Body = mem; |
| 86 | + |
| 87 | + await next(); |
| 88 | + |
| 89 | + if (isSubmit && ctx.Response.StatusCode == 400) |
| 90 | + { |
| 91 | + mem.Position = 0; |
| 92 | + var bodyText = await new StreamReader(mem).ReadToEndAsync(); |
| 93 | + var log = ctx.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("JobSubmitModelState"); |
| 94 | + log.LogWarning("Job submission returned 400. Response body: {Body}", bodyText); |
| 95 | + mem.Position = 0; |
| 96 | + } |
| 97 | + |
| 98 | + await mem.CopyToAsync(originalBody); |
| 99 | + ctx.Response.Body = originalBody; |
| 100 | +}); |
| 101 | + |
| 102 | +// Pipeline |
45 | 103 | if (app.Environment.IsDevelopment()) |
46 | 104 | { |
47 | 105 | app.UseSwagger(); |
48 | 106 | app.UseSwaggerUI(c => |
49 | 107 | { |
50 | 108 | c.SwaggerEndpoint("/swagger/v1/swagger.json", "Flink Job Gateway API v1"); |
51 | | - c.RoutePrefix = string.Empty; // Make Swagger UI the default page |
| 109 | + c.RoutePrefix = string.Empty; |
52 | 110 | }); |
53 | 111 | } |
54 | 112 |
|
55 | 113 | app.UseAuthorization(); |
56 | 114 | app.MapControllers(); |
57 | 115 |
|
58 | | -// Health check endpoint |
| 116 | +// Health endpoints |
59 | 117 | app.MapGet("/health", () => Results.Ok("OK")); |
60 | 118 | app.MapGet("/api/v1/health", () => Results.Ok(new { status = "OK", timestamp = DateTime.UtcNow })); |
61 | 119 |
|
62 | 120 | await app.RunAsync(); |
| 121 | + |
| 122 | +/// <summary> |
| 123 | +/// Logs ModelState validation errors (including polymorphic binding issues). |
| 124 | +/// </summary> |
| 125 | +internal sealed class ModelStateLoggingFilter : IActionFilter |
| 126 | +{ |
| 127 | + private readonly ILogger<ModelStateLoggingFilter> _logger; |
| 128 | + public ModelStateLoggingFilter(ILogger<ModelStateLoggingFilter> logger) => _logger = logger; |
| 129 | + |
| 130 | + public void OnActionExecuting(ActionExecutingContext context) |
| 131 | + { |
| 132 | + if (!context.ModelState.IsValid) |
| 133 | + { |
| 134 | + var errors = context.ModelState |
| 135 | + .Where(kv => kv.Value?.Errors.Count > 0) |
| 136 | + .Select(kv => $"{kv.Key}:{string.Join("|", kv.Value!.Errors.Select(e => e.ErrorMessage))}"); |
| 137 | + _logger.LogWarning("ModelState invalid for {Path}. Errors: {Errors}", |
| 138 | + context.HttpContext.Request.Path, |
| 139 | + string.Join("; ", errors)); |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + public void OnActionExecuted(ActionExecutedContext context) { } |
| 144 | +} |
0 commit comments