Skip to content

Commit 29eabc8

Browse files
mikemcdougallMike McDougall
andauthored
feat: implement PostGIS table discovery API (#57) (#88)
* feat: implement .NET Aspire orchestration with OpenTelemetry (#49) - Add Honua.AppHost project for local development orchestration - Configure PostgreSQL with PostGIS container (postgis/postgis:16-3.4) - Configure Redis container with Redis Commander for debugging - Add Honua.ServiceDefaults with shared OpenTelemetry configuration - Implement traces (ASP.NET Core, HttpClient), metrics (ASP.NET Core, runtime, custom) - Configure conditional OTLP export when OTEL_EXPORTER_OTLP_ENDPOINT is set - Add service discovery and health checks - Enable AOT compilation for production deployments - Update Honua.Server to integrate with ServiceDefaults Local development: dotnet run in src/Honua.AppHost starts full stack Production build: dotnet publish with native AOT compilation (24MB binary) * fix: resolve code style issues after merge - Added copyright headers to ServiceDefaults and AppHost projects - Removed unnecessary using statements via dotnet format - All builds pass with 0 warnings, 0 errors - All tests pass (104/104) - AOT build successful (27MB binary) * feat: implement PostGIS table discovery API (#57) - Add GET /api/admin/connections/{id}/tables endpoint - Implement PostgreSqlTableDiscoveryService for PostGIS table discovery - Query geometry_columns and geography_columns system views - Return table metadata including schema, name, geometry info, SRID, and columns - Add comprehensive integration tests with AOT-compatible JSON serialization - Include source-generated logging for performance - Follow greenfield project patterns with feature-based organization * fix: resolve AOT compilation errors in admin endpoints - Remove RequiresUnreferencedCode and RequiresDynamicCode attributes - Use explicit HttpContext parameter extraction instead of reflection-based binding - Replace method parameter injection with manual route value extraction - Ensures full AOT compatibility for minimal API endpoints * fix: use explicit RequestDelegate for full AOT compatibility - Replace MapGet with MapMethods using explicit RequestDelegate - Add HandleGetConnectionTables method for proper AOT-compatible endpoint registration - Eliminates all reflection-based parameter binding that caused AOT warnings - Ensures native compilation compatibility with zero warnings * fix: resolve admin endpoint routing and AOT compatibility issues - Use endpoints.Map() with HttpMethodMetadata for full AOT compatibility - Replace GetConnectionString() with IDatabaseConnectionProvider pattern - Follow proven health endpoint pattern for route registration - Write responses directly to HttpContext instead of returning IResult - Routing now works correctly - admin endpoints are being hit successfully * fix: resolve dependency inversion violation in AdminEndpoints - Replace ILogger<ITableDiscoveryService> with ILogger<AdminEndpoints> - Removes dependency from presentation layer on core service interfaces - Maintains proper architectural separation per Clean Architecture principles * fix: add proper test infrastructure for admin endpoints - Move TestDatabaseConnectionProvider to TestKit for reusability - Add IDatabaseConnectionProvider replacement in WebAppFixture - Fix logger dependency inversion issue with ILoggerFactory approach - Enables proper testing of database-dependent admin endpoints * wip: continue fixing test infrastructure for admin endpoints - Update WebAppFixture to properly handle PostgreSQL service registration - Remove DefaultConnection dependency for test environment - Add comprehensive test database connection setup - Work in progress: still debugging 500 errors in tests * fix: resolve all failing admin endpoint tests (#57) - Fix 500 error by passing DbConnection directly to avoid password extraction - Add DbConnection overload to ITableDiscoveryService interface - Implement catch-all routing to handle empty connection ID case (400 response) - Update PostgreSqlTableDiscoveryService with new connection overload - All 4 admin endpoint integration tests now passing Related to Issue #57: PostGIS table discovery API implementation --------- Co-authored-by: Mike McDougall <[email protected]>
1 parent b9fffda commit 29eabc8

File tree

10 files changed

+766
-2
lines changed

10 files changed

+766
-2
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
using System.Text.Json;
5+
using Honua.Core.Features.Infrastructure.Abstractions;
6+
using Honua.Server.Features.Admin.Models;
7+
using Honua.Server.Features.Admin.Services;
8+
9+
namespace Honua.Server.Features.Admin;
10+
11+
/// <summary>
12+
/// Admin endpoints for table discovery and connection management
13+
/// </summary>
14+
public static class AdminEndpoints
15+
{
16+
/// <summary>
17+
/// Configure admin endpoints using AOT-compatible routing
18+
/// </summary>
19+
public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints)
20+
{
21+
// Use Map with explicit HTTP method metadata to avoid MapGet reflection
22+
endpoints.Map("/api/admin/connections/{id}/tables", HandleGetConnectionTables)
23+
.WithDisplayName("Get Connection Tables")
24+
.WithTags("Admin")
25+
.WithMetadata(new HttpMethodMetadata(new[] { HttpMethods.Get }));
26+
27+
// Use catch-all parameter to handle edge cases like empty segments
28+
endpoints.Map("/api/admin/connections/{*path}", HandleGetConnectionTablesWithCatchAll)
29+
.WithDisplayName("Get Connection Tables - Catch All")
30+
.WithTags("Admin")
31+
.WithMetadata(new HttpMethodMetadata(new[] { HttpMethods.Get }));
32+
}
33+
34+
/// <summary>
35+
/// Handle catch-all cases for connections endpoints
36+
/// </summary>
37+
private static async Task HandleGetConnectionTablesWithCatchAll(HttpContext context)
38+
{
39+
var path = context.GetRouteValue("path")?.ToString() ?? "";
40+
41+
// Check if this is the tables endpoint with empty or invalid connection ID
42+
if (path.Equals("/tables", StringComparison.OrdinalIgnoreCase) ||
43+
path.Equals("tables", StringComparison.OrdinalIgnoreCase))
44+
{
45+
context.Response.StatusCode = 400; // Bad Request
46+
context.Response.ContentType = "text/plain; charset=utf-8";
47+
await context.Response.WriteAsync("Connection ID is required");
48+
return;
49+
}
50+
51+
// For other paths, return 404
52+
context.Response.StatusCode = 404;
53+
context.Response.ContentType = "application/problem+json; charset=utf-8";
54+
await context.Response.WriteAsync("""{"title":"Not Found","status":404,"detail":"The requested resource was not found."}""");
55+
}
56+
57+
/// <summary>
58+
/// Handle admin connection tables request
59+
/// Implements the API from Issue #57: GET /api/admin/connections/{id}/tables
60+
/// </summary>
61+
private static async Task HandleGetConnectionTables(HttpContext context)
62+
{
63+
// Ensure only GET requests
64+
if (!HttpMethods.IsGet(context.Request.Method))
65+
{
66+
context.Response.StatusCode = 405; // Method Not Allowed
67+
context.Response.ContentType = "application/problem+json; charset=utf-8";
68+
await context.Response.WriteAsync($$"""{"title":"Method Not Allowed","status":405,"detail":"Only GET requests are allowed for this endpoint"}""");
69+
return;
70+
}
71+
72+
// Extract connection ID from route
73+
var id = context.GetRouteValue("id")?.ToString();
74+
75+
// Validate input
76+
if (string.IsNullOrWhiteSpace(id))
77+
{
78+
context.Response.StatusCode = 400; // Bad Request
79+
context.Response.ContentType = "text/plain; charset=utf-8";
80+
await context.Response.WriteAsync("Connection ID is required");
81+
return;
82+
}
83+
84+
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Admin.TableDiscovery");
85+
86+
try
87+
{
88+
// For this initial implementation, use the default database connection for all connection IDs
89+
// In a full implementation, this would look up the connection by ID and validate it exists
90+
var connectionProvider = context.RequestServices.GetRequiredService<IDatabaseConnectionProvider>();
91+
var tableDiscoveryService = context.RequestServices.GetRequiredService<ITableDiscoveryService>();
92+
93+
await using var connection = await connectionProvider.OpenConnectionAsync(context.RequestAborted);
94+
95+
// Pass the opened connection directly to avoid password extraction issues
96+
var tables = await tableDiscoveryService.DiscoverPostGisTablesAsync(
97+
connection,
98+
context.RequestAborted);
99+
100+
var response = new TableDiscoveryResponse
101+
{
102+
Tables = tables
103+
};
104+
105+
AdminLog.TableDiscoverySuccessful(logger, tables.Count, id);
106+
107+
// Return JSON response with AOT-compatible serialization
108+
context.Response.StatusCode = 200;
109+
context.Response.ContentType = "application/json; charset=utf-8";
110+
await JsonSerializer.SerializeAsync(context.Response.Body, response,
111+
TableDiscoveryJsonContext.Default.TableDiscoveryResponse,
112+
context.RequestAborted);
113+
}
114+
catch (Exception ex)
115+
{
116+
AdminLog.TableDiscoveryFailed(logger, ex, id);
117+
118+
context.Response.StatusCode = 500; // Internal Server Error
119+
context.Response.ContentType = "application/problem+json; charset=utf-8";
120+
await context.Response.WriteAsync($$"""{"title":"Table Discovery Failed","status":500,"detail":"An error occurred while discovering tables. Please check the connection and try again."}""");
121+
}
122+
}
123+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
namespace Honua.Server.Features.Admin;
5+
6+
/// <summary>
7+
/// Source-generated logger for Admin features (AOT compatible)
8+
/// </summary>
9+
public static partial class AdminLog
10+
{
11+
/// <summary>
12+
/// Log when connection not found for table discovery
13+
/// </summary>
14+
[LoggerMessage(
15+
EventId = 4001,
16+
Level = LogLevel.Warning,
17+
Message = "No connection string found for connection ID {ConnectionId}")]
18+
public static partial void ConnectionNotFound(ILogger logger, string connectionId);
19+
20+
/// <summary>
21+
/// Log successful table discovery
22+
/// </summary>
23+
[LoggerMessage(
24+
EventId = 4002,
25+
Level = LogLevel.Information,
26+
Message = "Successfully discovered {Count} tables for connection {ConnectionId}")]
27+
public static partial void TableDiscoverySuccessful(ILogger logger, int count, string connectionId);
28+
29+
/// <summary>
30+
/// Log table discovery failure
31+
/// </summary>
32+
[LoggerMessage(
33+
EventId = 4003,
34+
Level = LogLevel.Error,
35+
Message = "Failed to discover tables for connection {ConnectionId}")]
36+
public static partial void TableDiscoveryFailed(ILogger logger, Exception ex, string connectionId);
37+
38+
/// <summary>
39+
/// Log PostGIS table discovery completion
40+
/// </summary>
41+
[LoggerMessage(
42+
EventId = 4004,
43+
Level = LogLevel.Information,
44+
Message = "Discovered {Count} PostGIS tables")]
45+
public static partial void PostGisTablesDiscovered(ILogger logger, int count);
46+
47+
/// <summary>
48+
/// Log PostGIS table discovery error
49+
/// </summary>
50+
[LoggerMessage(
51+
EventId = 4005,
52+
Level = LogLevel.Error,
53+
Message = "Error discovering PostGIS tables")]
54+
public static partial void PostGisDiscoveryError(ILogger logger, Exception ex);
55+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
using System.Text.Json.Serialization;
5+
6+
namespace Honua.Server.Features.Admin.Models;
7+
8+
/// <summary>
9+
/// Information about a discovered table with spatial data
10+
/// </summary>
11+
public sealed class TableInfo
12+
{
13+
/// <summary>
14+
/// Database schema name (e.g., "public")
15+
/// </summary>
16+
public required string Schema { get; init; }
17+
18+
/// <summary>
19+
/// Table name
20+
/// </summary>
21+
public required string Table { get; init; }
22+
23+
/// <summary>
24+
/// Name of the geometry column
25+
/// </summary>
26+
public string? GeometryColumn { get; init; }
27+
28+
/// <summary>
29+
/// Geometry type (e.g., POINT, POLYGON, MULTIPOLYGON)
30+
/// </summary>
31+
public string? GeometryType { get; init; }
32+
33+
/// <summary>
34+
/// Spatial Reference Identifier (SRID)
35+
/// </summary>
36+
public int? Srid { get; init; }
37+
38+
/// <summary>
39+
/// Estimated row count
40+
/// </summary>
41+
public long? EstimatedRows { get; init; }
42+
43+
/// <summary>
44+
/// All columns in the table
45+
/// </summary>
46+
public List<ColumnInfo> Columns { get; init; } = new();
47+
}
48+
49+
/// <summary>
50+
/// Information about a table column
51+
/// </summary>
52+
public sealed class ColumnInfo
53+
{
54+
/// <summary>
55+
/// Column name
56+
/// </summary>
57+
public required string Name { get; init; }
58+
59+
/// <summary>
60+
/// Column data type
61+
/// </summary>
62+
public required string DataType { get; init; }
63+
64+
/// <summary>
65+
/// Whether the column allows null values
66+
/// </summary>
67+
public bool IsNullable { get; init; }
68+
69+
/// <summary>
70+
/// Whether this is a primary key column
71+
/// </summary>
72+
public bool IsPrimaryKey { get; init; }
73+
74+
/// <summary>
75+
/// Maximum length for character types
76+
/// </summary>
77+
public int? MaxLength { get; init; }
78+
}
79+
80+
/// <summary>
81+
/// Response from table discovery endpoint
82+
/// </summary>
83+
public sealed class TableDiscoveryResponse
84+
{
85+
/// <summary>
86+
/// List of discovered tables
87+
/// </summary>
88+
public List<TableInfo> Tables { get; init; } = new();
89+
}
90+
91+
/// <summary>
92+
/// Response model with JSON source generation for AOT compatibility
93+
/// </summary>
94+
[JsonSerializable(typeof(TableDiscoveryResponse))]
95+
[JsonSerializable(typeof(TableInfo))]
96+
[JsonSerializable(typeof(ColumnInfo))]
97+
[JsonSourceGenerationOptions(
98+
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
99+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
100+
public partial class TableDiscoveryJsonContext : JsonSerializerContext
101+
{
102+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
using System.Data.Common;
5+
using Honua.Server.Features.Admin.Models;
6+
7+
namespace Honua.Server.Features.Admin.Services;
8+
9+
/// <summary>
10+
/// Service for discovering spatial tables in a database
11+
/// </summary>
12+
public interface ITableDiscoveryService
13+
{
14+
/// <summary>
15+
/// Discover all spatial tables in a PostGIS database
16+
/// </summary>
17+
/// <param name="connectionString">PostgreSQL connection string</param>
18+
/// <param name="cancellationToken">Cancellation token</param>
19+
/// <returns>List of discovered tables with metadata</returns>
20+
Task<List<TableInfo>> DiscoverPostGisTablesAsync(
21+
string connectionString,
22+
CancellationToken cancellationToken = default);
23+
24+
/// <summary>
25+
/// Discover all spatial tables in a PostGIS database using an existing connection
26+
/// </summary>
27+
/// <param name="connection">Open database connection</param>
28+
/// <param name="cancellationToken">Cancellation token</param>
29+
/// <returns>List of discovered tables with metadata</returns>
30+
Task<List<TableInfo>> DiscoverPostGisTablesAsync(
31+
DbConnection connection,
32+
CancellationToken cancellationToken = default);
33+
}

0 commit comments

Comments
 (0)