1- // AuthorApiServer — minimal reference ASP.NET / HttpListener server implementing
1+ // AuthorApiServer — minimal reference ASP.NET Core (Kestrel) server implementing
22// the cross-port REST API contract for the Author entity from the
33// api-contract-conformance corpus.
44//
55// Why hand-rolled (vs the RoutesGenerator output): the generator's output is a
6- // Minimal-API mapping requiring a full WebApplication host pulling EF Core +
7- // Kestrel. For a 10-route contract test that's disproportionate, and the
8- // pattern matches the Java/Kotlin reference runners byte-for-byte. The corpus
6+ // Minimal-API mapping over EF Core. This server is the independent fixed point
7+ // the generated lane is measured against: one catch-all handler over raw Npgsql,
8+ // and the pattern matches the Java/Kotlin reference runners byte-for-byte. The corpus
99// is the contract both must satisfy.
1010//
1111// Backend is a Postgres testcontainer (matches the persistence-conformance
1212// runner — keeps a single integration backend across both corpora and proves
1313// the wire shape against real autoincrement + null semantics).
1414
1515using System . Globalization ;
16- using System . Net ;
16+ using Microsoft . AspNetCore . Builder ;
17+ using Microsoft . AspNetCore . Http ;
18+ using Microsoft . Extensions . Logging ;
1719using System . Text ;
1820using System . Text . Json ;
1921using System . Text . Json . Nodes ;
@@ -77,18 +79,15 @@ private sealed record FilterResult(List<FilterPredicate> Predicates, string? Err
7779 private const string TimestampFmt = "yyyy-MM-ddTHH:mm:ss" ;
7880
7981 private readonly PostgresContainer _pg ;
80- private readonly HttpListener _listener ;
81- private readonly Task _loop ;
82- private readonly CancellationTokenSource _cts = new ( ) ;
82+ private readonly WebApplication _app ;
8383
8484 public string BaseUrl { get ; }
8585
86- private AuthorApiServer ( PostgresContainer pg , HttpListener listener , string baseUrl )
86+ private AuthorApiServer ( PostgresContainer pg , WebApplication app , string baseUrl )
8787 {
8888 _pg = pg ;
89- _listener = listener ;
89+ _app = app ;
9090 BaseUrl = baseUrl ;
91- _loop = Task . Run ( AcceptLoopAsync ) ;
9291 }
9392
9493 public static async Task < AuthorApiServer > StartAsync ( PostgresContainer pg )
@@ -114,20 +113,23 @@ bio VARCHAR(1000),
114113 )" ;
115114 await cmd . ExecuteNonQueryAsync ( ) ;
116115 }
117- int port = PickFreePort ( ) ;
118- var listener = new HttpListener ( ) ;
119- string baseUrl = $ "http://127.0.0.1:{ port } ";
120- listener . Prefixes . Add ( baseUrl + "/" ) ;
121- listener . Start ( ) ;
122- return new AuthorApiServer ( pg , listener , baseUrl ) ;
116+ var builder = WebApplication . CreateBuilder ( ) ;
117+ LoopbackKestrel . BindAnyPort ( builder ) ;
118+ builder . Logging . ClearProviders ( ) ;
119+ var app = builder . Build ( ) ;
120+ AuthorApiServer ? server = null ;
121+ // The bound address exists only after StartAsync, and no client can reach the
122+ // server before it is returned, so the handler is wired before the instance exists.
123+ app . Run ( ctx => server ! . HandleAsync ( ctx ) ) ;
124+ await app . StartAsync ( ) ;
125+ server = new AuthorApiServer ( pg , app , LoopbackKestrel . BoundUrl ( app ) ) ;
126+ return server ;
123127 }
124128
125129 public async ValueTask DisposeAsync ( )
126130 {
127- _cts . Cancel ( ) ;
128- try { _listener . Stop ( ) ; _listener . Close ( ) ; } catch { /* ignored */ }
129- try { await _loop ; } catch { /* ignored */ }
130- _cts . Dispose ( ) ;
131+ try { await _app . StopAsync ( ) ; } catch { /* ignored */ }
132+ try { await _app . DisposeAsync ( ) ; } catch { /* ignored */ }
131133 }
132134
133135 // ----- seed / truncate -----
@@ -183,22 +185,7 @@ public async Task ApplySeedAsync(IReadOnlyList<Dictionary<string, object?>> rows
183185
184186 // ----- HTTP loop -----
185187
186- private async Task AcceptLoopAsync ( )
187- {
188- while ( ! _cts . IsCancellationRequested )
189- {
190- HttpListenerContext ctx ;
191- try
192- {
193- ctx = await _listener . GetContextAsync ( ) ;
194- }
195- catch ( HttpListenerException ) { break ; }
196- catch ( ObjectDisposedException ) { break ; }
197- _ = Task . Run ( ( ) => HandleAsync ( ctx ) ) ;
198- }
199- }
200-
201- private async Task HandleAsync ( HttpListenerContext ctx )
188+ private async Task HandleAsync ( HttpContext ctx )
202189 {
203190 try
204191 {
@@ -213,18 +200,14 @@ private async Task HandleAsync(HttpListenerContext ctx)
213200 }
214201 catch { /* nothing we can do */ }
215202 }
216- finally
217- {
218- try { ctx . Response . Close ( ) ; } catch { /* ignored */ }
219- }
220203 }
221204
222- private async Task DispatchAsync ( HttpListenerContext ctx )
205+ private async Task DispatchAsync ( HttpContext ctx )
223206 {
224- string method = ctx . Request . HttpMethod . ToUpperInvariant ( ) ;
225- string rawPath = ctx . Request . Url ? . AbsolutePath ?? "" ;
226- var qs = ctx . Request . QueryString ;
227- string rawQuery = ctx . Request . Url ? . Query ?? "" ;
207+ string method = ctx . Request . Method . ToUpperInvariant ( ) ;
208+ string rawPath = ctx . Request . Path . Value ?? "" ;
209+ var qs = ctx . Request . Query ;
210+ string rawQuery = ctx . Request . QueryString . Value ?? "" ;
228211 if ( rawQuery . StartsWith ( "?" ) ) rawQuery = rawQuery . Substring ( 1 ) ;
229212
230213 string trimmed = rawPath . StartsWith ( "/api/authors" )
@@ -246,8 +229,8 @@ private async Task DispatchAsync(HttpListenerContext ctx)
246229 }
247230
248231 private async Task ListAuthorsAsync (
249- HttpListenerContext ctx ,
250- System . Collections . Specialized . NameValueCollection qs ,
232+ HttpContext ctx ,
233+ IQueryCollection qs ,
251234 string rawQuery )
252235 {
253236 string sortField = "id" ;
@@ -504,7 +487,7 @@ private static void BindAll(NpgsqlCommand cmd, List<object?> values)
504487 cmd . Parameters . AddWithValue ( "@p" + i , values [ i ] ?? ( object ) DBNull . Value ) ;
505488 }
506489
507- private async Task GetAuthorAsync ( HttpListenerContext ctx , string idStr )
490+ private async Task GetAuthorAsync ( HttpContext ctx , string idStr )
508491 {
509492 long ? id = ParseLongOrNull ( idStr ) ;
510493 if ( id is null ) { await SendJsonAsync ( ctx , 400 , new Dictionary < string , object ? > { [ "error" ] = "invalid_id" } ) ; return ; }
@@ -529,7 +512,7 @@ private static bool ExceedsAuthorMaxLength(Dictionary<string, object?> body) =>
529512 ( body . GetValueOrDefault ( "name" ) is string n && n . Length > 100 )
530513 || ( body . GetValueOrDefault ( "bio" ) is string b && b . Length > 1000 ) ;
531514
532- private async Task CreateAuthorAsync ( HttpListenerContext ctx )
515+ private async Task CreateAuthorAsync ( HttpContext ctx )
533516 {
534517 var parsed = await ReadJsonBodyAsync ( ctx ) ;
535518 if ( parsed is not Dictionary < string , object ? > body )
@@ -579,7 +562,7 @@ private async Task CreateAuthorAsync(HttpListenerContext ctx)
579562 await SendJsonAsync ( ctx , 201 , row ) ;
580563 }
581564
582- private async Task UpdateAuthorAsync ( HttpListenerContext ctx , string idStr )
565+ private async Task UpdateAuthorAsync ( HttpContext ctx , string idStr )
583566 {
584567 long ? id = ParseLongOrNull ( idStr ) ;
585568 if ( id is null ) { await SendJsonAsync ( ctx , 400 , new Dictionary < string , object ? > { [ "error" ] = "invalid_id" } ) ; return ; }
@@ -662,7 +645,7 @@ private async Task UpdateAuthorAsync(HttpListenerContext ctx, string idStr)
662645 await SendJsonAsync ( ctx , 200 , row ) ;
663646 }
664647
665- private async Task DeleteAuthorAsync ( HttpListenerContext ctx , string idStr )
648+ private async Task DeleteAuthorAsync ( HttpContext ctx , string idStr )
666649 {
667650 long ? id = ParseLongOrNull ( idStr ) ;
668651 if ( id is null ) { await SendJsonAsync ( ctx , 400 , new Dictionary < string , object ? > { [ "error" ] = "invalid_id" } ) ; return ; }
@@ -708,9 +691,9 @@ private async Task DeleteAuthorAsync(HttpListenerContext ctx, string idStr)
708691 return rdr . IsDBNull ( ord ) ? null : rdr . GetDateTime ( ord ) . ToString ( TimestampFmt , CultureInfo . InvariantCulture ) ;
709692 }
710693
711- private static async Task < object ? > ReadJsonBodyAsync ( HttpListenerContext ctx )
694+ private static async Task < object ? > ReadJsonBodyAsync ( HttpContext ctx )
712695 {
713- using var reader = new StreamReader ( ctx . Request . InputStream , ctx . Request . ContentEncoding ) ;
696+ using var reader = new StreamReader ( ctx . Request . Body , Encoding . UTF8 ) ;
714697 string text = await reader . ReadToEndAsync ( ) ;
715698 if ( string . IsNullOrEmpty ( text ) ) return null ;
716699 return JsonNodeToObject ( JsonNode . Parse ( text ) ) ;
@@ -743,20 +726,20 @@ private async Task DeleteAuthorAsync(HttpListenerContext ctx, string idStr)
743726 return null ;
744727 }
745728
746- private static async Task SendJsonAsync ( HttpListenerContext ctx , int status , object body )
729+ private static async Task SendJsonAsync ( HttpContext ctx , int status , object body )
747730 {
748731 byte [ ] bytes = Encoding . UTF8 . GetBytes ( JsonSerializer . Serialize ( body , JsonOpts ) ) ;
749732 ctx . Response . StatusCode = status ;
750733 ctx . Response . ContentType = "application/json" ;
751- ctx . Response . ContentLength64 = bytes . Length ;
752- await ctx . Response . OutputStream . WriteAsync ( bytes ) ;
734+ ctx . Response . ContentLength = bytes . Length ;
735+ await ctx . Response . Body . WriteAsync ( bytes ) ;
753736 }
754737
755- private static async Task SendNoContentAsync ( HttpListenerContext ctx )
738+ private static async Task SendNoContentAsync ( HttpContext ctx )
756739 {
757740 ctx . Response . StatusCode = 204 ;
758- ctx . Response . ContentLength64 = 0 ;
759- await ctx . Response . OutputStream . FlushAsync ( ) ;
741+ ctx . Response . ContentLength = 0 ;
742+ await ctx . Response . Body . FlushAsync ( ) ;
760743 }
761744
762745 private static readonly JsonSerializerOptions JsonOpts = new ( )
@@ -778,15 +761,4 @@ private static DateTime ParseTimestamp(string s) =>
778761 if ( s is null ) return null ;
779762 return long . TryParse ( s , NumberStyles . Integer , CultureInfo . InvariantCulture , out var n ) ? n : null ;
780763 }
781-
782- private static int PickFreePort ( )
783- {
784- // Bind to port 0, read what the OS gave us, release. Race-free for our
785- // single-instance test usage; HttpListener will re-bind in StartAsync.
786- var l = new System . Net . Sockets . TcpListener ( System . Net . IPAddress . Loopback , 0 ) ;
787- l . Start ( ) ;
788- int port = ( ( System . Net . IPEndPoint ) l . LocalEndpoint ) . Port ;
789- l . Stop ( ) ;
790- return port ;
791- }
792764}
0 commit comments