From 626f63fcd994cbb2fb2c0b282f1facd3590fba7d Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 13:26:42 -0500 Subject: [PATCH 1/3] feat(docs): OpenAPI documents structured for Swagger UI - One tag per table (with columns, key, related tables), and every operation tagged, so Swagger UI groups by table instead of one 'default' list. - Summaries read correctly ('Create a row in customers', not 'Create a customers'); operationIds (customers_list, ...). - By-key paths declare their path parameters: 'Try it out' now has an {id} input instead of sending a literal {id}. - REST list responses documented under 'resource' (what REST returns); REST create documented as 201. - info.description: a short guide to the query options, with examples built from the service's real columns; per-operation hints incl. $expand targets. - GeneratorVersion 1.1 (refreshes ETag-cached documents). --- src/EzOdata.Docs/OpenApiGenerator.cs | 307 ++++++++++++++---- .../OpenApiDocsTests.cs | 37 +++ 2 files changed, 274 insertions(+), 70 deletions(-) diff --git a/src/EzOdata.Docs/OpenApiGenerator.cs b/src/EzOdata.Docs/OpenApiGenerator.cs index 1c5439f..dca230b 100644 --- a/src/EzOdata.Docs/OpenApiGenerator.cs +++ b/src/EzOdata.Docs/OpenApiGenerator.cs @@ -15,7 +15,7 @@ public enum ApiDialect { ODataV4, Rest } /// public sealed class OpenApiGenerator { - public const string GeneratorVersion = "1.0"; + public const string GeneratorVersion = "1.1"; public string Generate( SchemaSnapshot schema, ApiDialect dialect, string serviceName, string serviceLabel, @@ -27,6 +27,7 @@ public string Generate( ["info"] = new JsonObject { ["title"] = $"{serviceLabel} ({(dialect == ApiDialect.ODataV4 ? "OData v4" : "REST")})", + ["description"] = Describe(schema, dialect, serviceRoot), ["version"] = "1.0", ["x-ez-schema-version"] = schemaVersion, ["x-ez-generator-version"] = GeneratorVersion, @@ -49,6 +50,7 @@ public string Generate( new JsonObject { ["apiKey"] = new JsonArray() }, new JsonObject { ["bearer"] = new JsonArray() }, }, + ["tags"] = BuildTags(schema), ["paths"] = dialect == ApiDialect.ODataV4 ? BuildODataPaths(schema) : BuildRestPaths(schema), }; @@ -150,23 +152,20 @@ private static JsonObject BuildODataPaths(SchemaSnapshot schema) foreach (var table in schema.Tables) { var set = table.ExposedName; - var collection = new JsonObject - { - ["get"] = ODataListOperation(table), - }; - if (table.Writable) - { - collection["post"] = ODataCreateOperation(table); - } - + var collection = new JsonObject { ["get"] = ODataListOperation(schema, table) }; + if (table.Writable) collection["post"] = CreateOperation(table); paths[$"/{set}"] = collection; if (table.HasKey) { - var byKey = new JsonObject { ["get"] = ODataGetOperation(table) }; + var byKey = new JsonObject + { + ["parameters"] = KeyParameters(table, ApiDialect.ODataV4), + ["get"] = GetOperation(table), + }; if (table.Writable) { - byKey["patch"] = ODataUpdateOperation(table); + byKey["patch"] = UpdateOperation(table); byKey["delete"] = DeleteOperation(table); } @@ -184,15 +183,19 @@ private static JsonObject BuildRestPaths(SchemaSnapshot schema) { var set = table.ExposedName; var collection = new JsonObject { ["get"] = RestListOperation(table) }; - if (table.Writable) collection["post"] = ODataCreateOperation(table); + if (table.Writable) collection["post"] = CreateOperation(table); paths[$"/_table/{set}"] = collection; if (table.HasKey) { - var byKey = new JsonObject { ["get"] = ODataGetOperation(table) }; + var byKey = new JsonObject + { + ["parameters"] = KeyParameters(table, ApiDialect.Rest), + ["get"] = GetOperation(table), + }; if (table.Writable) { - byKey["patch"] = ODataUpdateOperation(table); + byKey["patch"] = UpdateOperation(table); byKey["delete"] = DeleteOperation(table); } @@ -203,80 +206,244 @@ private static JsonObject BuildRestPaths(SchemaSnapshot schema) return paths; } - private static JsonObject ODataListOperation(TableModel table) => new() + // ---- document-level description and per-table tags (what Swagger UI renders as the header and groups) ---- + + private static string Describe(SchemaSnapshot schema, ApiDialect dialect, Uri serviceRoot) { - ["summary"] = $"Query {table.ExposedName}", - ["parameters"] = new JsonArray + var tables = schema.Tables.Count(t => !t.IsView); + var views = schema.Tables.Count(t => t.IsView); + var sample = schema.Tables.FirstOrDefault(t => !t.IsView) ?? schema.Tables.FirstOrDefault(); + var shape = views > 0 ? $"{tables} tables and {views} views" : $"{tables} tables"; + var text = new StringBuilder(); + text.Append("Generated from the live database schema: ").Append(shape) + .Append(", each with the operations your role allows.\n\n"); + + if (dialect == ApiDialect.ODataV4) { - QueryParam("$filter", "OData filter expression"), - QueryParam("$select", "Comma-separated properties"), - QueryParam("$orderby", "Sort expression"), - QueryParam("$top", "Max rows", "integer"), - QueryParam("$skip", "Rows to skip", "integer"), - QueryParam("$count", "Include total count", "boolean"), - QueryParam("$expand", "Related entities to include"), - }, - ["responses"] = OkArrayResponse(table), - }; + text.Append("**Query options** (on any collection):\n\n") + .Append("| Option | Example |\n|---|---|\n") + .Append("| `$filter` | `").Append(sample is null ? "id eq 1" : FilterExample(sample, rest: false)).Append("` |\n") + .Append("| `$select` | `").Append(sample is null ? "id" : SelectExample(sample)).Append("` |\n") + .Append("| `$orderby` | `").Append(sample is null ? "id desc" : OrderExample(sample, rest: false)).Append("` |\n") + .Append("| `$top` / `$skip` | `$top=20&$skip=40` |\n") + .Append("| `$count` | `true` (adds `@odata.count`) |\n") + .Append("| `$expand` | a related table, listed on each operation |\n\n") + .Append("Service document: `").Append(serviceRoot.ToString().TrimEnd('/')).Append("/`. ") + .Append("Schema (CSDL): `").Append(serviceRoot.ToString().TrimEnd('/')).Append("/$metadata`."); + } + else + { + text.Append("**Query parameters** (on any `/_table/{name}`):\n\n") + .Append("| Parameter | Example |\n|---|---|\n") + .Append("| `filter` | `").Append(sample is null ? "id = 1" : FilterExample(sample, rest: true)).Append("` |\n") + .Append("| `fields` | `").Append(sample is null ? "id" : SelectExample(sample)).Append("` |\n") + .Append("| `order` | `").Append(sample is null ? "id desc" : OrderExample(sample, rest: true)).Append("` |\n") + .Append("| `limit` / `offset` | `limit=20&offset=40` |\n") + .Append("| `include_count` | `true` (adds `meta.count`) |\n\n") + .Append("Rows are returned under `resource`, with paging in `meta.next`."); + } + + return text.ToString(); + } - private static JsonObject RestListOperation(TableModel table) => new() + private static JsonArray BuildTags(SchemaSnapshot schema) { - ["summary"] = $"Query {table.ExposedName}", - ["parameters"] = new JsonArray + var tags = new JsonArray(); + foreach (var table in schema.Tables) { - QueryParam("filter", "SQL-ish filter expression"), - QueryParam("fields", "Comma-separated fields"), - QueryParam("order", "Sort expression"), - QueryParam("limit", "Max rows", "integer"), - QueryParam("offset", "Rows to skip", "integer"), - QueryParam("include_count", "Include total count", "boolean"), - }, - ["responses"] = OkArrayResponse(table), - }; + var facts = new List { table.IsView ? "View" : "Table", $"{table.Columns.Count} columns" }; + if (table.HasKey) facts.Add($"key: {string.Join(", ", table.PrimaryKey)}"); + if (!table.Writable) facts.Add("read-only"); + var navigations = Navigations(schema, table); + if (navigations.Count > 0) facts.Add($"related: {string.Join(", ", navigations)}"); + + var description = string.Join(" · ", facts); + if (!string.IsNullOrWhiteSpace(table.Comment)) description = $"{table.Comment} — {description}"; + tags.Add(new JsonObject { ["name"] = table.ExposedName, ["description"] = description }); + } + + return tags; + } - private static JsonObject ODataGetOperation(TableModel table) => new() + // ---- operations ---- + + private static JsonObject ODataListOperation(SchemaSnapshot schema, TableModel table) { - ["summary"] = $"Get a {table.ExposedName} by key", - ["responses"] = new JsonObject + var navigations = Navigations(schema, table); + var parameters = new JsonArray + { + QueryParam("$filter", $"Filter rows, e.g. `{FilterExample(table, rest: false)}`"), + QueryParam("$select", $"Columns to return, e.g. `{SelectExample(table)}`"), + QueryParam("$orderby", $"Sort, e.g. `{OrderExample(table, rest: false)}`"), + QueryParam("$top", "Maximum rows to return", "integer"), + QueryParam("$skip", "Rows to skip (paging)", "integer"), + QueryParam("$count", "Include the total row count (`@odata.count`)", "boolean"), + }; + if (navigations.Count > 0) + { + parameters.Add(QueryParam("$expand", $"Include related rows: {string.Join(", ", navigations.Select(n => $"`{n}`"))}")); + } + + return Operation(table, "list", $"Query {table.ExposedName}", + parameters: parameters, responses: OkArrayResponse(table, "value")); + } + + private static JsonObject RestListOperation(TableModel table) => Operation(table, "list", $"Query {table.ExposedName}", + parameters: new JsonArray + { + QueryParam("filter", $"Filter rows, e.g. `{FilterExample(table, rest: true)}`"), + QueryParam("fields", $"Columns to return, e.g. `{SelectExample(table)}`"), + QueryParam("order", $"Sort, e.g. `{OrderExample(table, rest: true)}`"), + QueryParam("limit", "Maximum rows to return", "integer"), + QueryParam("offset", "Rows to skip (paging)", "integer"), + QueryParam("include_count", "Include the total row count (`meta.count`)", "boolean"), + }, + responses: OkArrayResponse(table, "resource")); + + private static JsonObject GetOperation(TableModel table) => Operation(table, "get", $"Get a row from {table.ExposedName} by key", + responses: new JsonObject { ["200"] = JsonResponse($"#/components/schemas/{table.ExposedName}"), ["404"] = new JsonObject { ["description"] = "Not found" }, - }, - }; + }); - private static JsonObject ODataCreateOperation(TableModel table) => new() - { - ["summary"] = $"Create a {table.ExposedName}", - ["requestBody"] = RequestBody($"#/components/schemas/{table.ExposedName}Create"), - ["responses"] = new JsonObject + private static JsonObject CreateOperation(TableModel table) => Operation(table, "create", $"Create a row in {table.ExposedName}", + requestBody: RequestBody($"#/components/schemas/{table.ExposedName}Create"), + responses: new JsonObject { - ["201"] = JsonResponse($"#/components/schemas/{table.ExposedName}"), + ["201"] = JsonResponse($"#/components/schemas/{table.ExposedName}", "Created"), ["400"] = new JsonObject { ["description"] = "Validation error" }, - ["409"] = new JsonObject { ["description"] = "Conflict" }, - }, - }; + ["409"] = new JsonObject { ["description"] = "Conflict (for example a unique constraint)" }, + }); - private static JsonObject ODataUpdateOperation(TableModel table) => new() - { - ["summary"] = $"Update a {table.ExposedName}", - ["requestBody"] = RequestBody($"#/components/schemas/{table.ExposedName}Update"), - ["responses"] = new JsonObject + private static JsonObject UpdateOperation(TableModel table) => Operation(table, "update", $"Update a row in {table.ExposedName}", + description: "Only the properties you send are changed.", + requestBody: RequestBody($"#/components/schemas/{table.ExposedName}Update"), + responses: new JsonObject { ["200"] = JsonResponse($"#/components/schemas/{table.ExposedName}"), + ["400"] = new JsonObject { ["description"] = "Validation error" }, ["404"] = new JsonObject { ["description"] = "Not found" }, - }, - }; + }); - private static JsonObject DeleteOperation(TableModel table) => new() - { - ["summary"] = $"Delete a {table.ExposedName}", - ["responses"] = new JsonObject + private static JsonObject DeleteOperation(TableModel table) => Operation(table, "delete", $"Delete a row from {table.ExposedName}", + responses: new JsonObject { ["204"] = new JsonObject { ["description"] = "Deleted" }, ["404"] = new JsonObject { ["description"] = "Not found" }, - }, + }); + + private static JsonObject Operation(TableModel table, string verb, string summary, string? description = null, + JsonArray? parameters = null, JsonObject? requestBody = null, JsonObject? responses = null) + { + var operation = new JsonObject + { + ["tags"] = new JsonArray { table.ExposedName }, + ["operationId"] = $"{OperationName(table.ExposedName)}_{verb}", + ["summary"] = summary, + }; + if (description is not null) operation["description"] = description; + if (parameters is not null) operation["parameters"] = parameters; + if (requestBody is not null) operation["requestBody"] = requestBody; + operation["responses"] = responses ?? new JsonObject(); + return operation; + } + + /// Path parameters for by-key paths: {id} for single keys, one per column for composite keys. + private static JsonArray KeyParameters(TableModel table, ApiDialect dialect) + { + var parameters = new JsonArray(); + if (table.PrimaryKey.Count == 1 || dialect == ApiDialect.Rest) + { + var column = table.FindColumn(table.PrimaryKey[0]); + parameters.Add(PathParam("id", $"Key ({string.Join(", ", table.PrimaryKey)})", column)); + } + else + { + foreach (var key in table.PrimaryKey) + { + parameters.Add(PathParam(key, $"Key part {key}", table.FindColumn(key))); + } + } + + return parameters; + } + + private static JsonObject PathParam(string name, string description, ColumnModel? column) => new() + { + ["name"] = name, + ["in"] = "path", + ["required"] = true, + ["description"] = description, + ["schema"] = column is null ? new JsonObject { ["type"] = "string" } : KeySchema(column), }; + private static JsonObject KeySchema(ColumnModel column) + { + var schema = PropertySchema(column); + schema.Remove("nullable"); + schema.Remove("description"); + return schema; + } + + // ---- examples built from the table's real columns ---- + + private static List Navigations(SchemaSnapshot schema, TableModel table) + { + var names = table.ForeignKeys.Select(fk => fk.NavToOne).ToList(); + foreach (var other in schema.Tables) + { + names.AddRange(other.ForeignKeys.Where(fk => fk.RefTable == table.ExposedName).Select(fk => fk.NavToMany)); + } + + return names.Distinct(StringComparer.Ordinal).ToList(); + } + + private static ColumnModel? FirstColumn(TableModel table, Func predicate) => + table.Columns.FirstOrDefault(c => !c.IsPrimaryKey && predicate(c)) ?? table.Columns.FirstOrDefault(predicate); + + private static bool IsText(ColumnModel c) => c.EdmType == "Edm.String" && c.AllowedValues is null; + + private static bool IsNumber(ColumnModel c) => c.EdmType is "Edm.Int16" or "Edm.Int32" or "Edm.Int64" or "Edm.Decimal" or "Edm.Double" or "Edm.Single"; + + private static bool IsFlag(ColumnModel c) => + c.EdmType == "Edm.Boolean" + || c.ExposedName.StartsWith("is_", StringComparison.OrdinalIgnoreCase) + || c.ExposedName.StartsWith("has_", StringComparison.OrdinalIgnoreCase) + || (c.ExposedName.Length > 2 && c.ExposedName.StartsWith("Is", StringComparison.Ordinal) && char.IsUpper(c.ExposedName[2])); + + /// A realistic filter for this table: text contains, else a numeric comparison, else the key. + private static string FilterExample(TableModel table, bool rest) + { + if (FirstColumn(table, IsText) is { } text) + { + return rest ? $"{text.ExposedName} like '%a%'" : $"contains({text.ExposedName},'a')"; + } + + if (FirstColumn(table, c => IsNumber(c) && !c.IsPrimaryKey && !IsFlag(c)) is { } number) + { + return rest ? $"{number.ExposedName} > 10" : $"{number.ExposedName} gt 10"; + } + + var key = table.PrimaryKey.FirstOrDefault() ?? table.Columns.FirstOrDefault()?.ExposedName ?? "id"; + return rest ? $"{key} = 1" : $"{key} eq 1"; + } + + private static string SelectExample(TableModel table) => + string.Join(",", table.Columns.Take(Math.Min(3, table.Columns.Count)).Select(c => c.ExposedName)); + + private static string OrderExample(TableModel table, bool rest) + { + var column = FirstColumn(table, IsText) ?? table.Columns.FirstOrDefault(); + return column is null ? "id desc" : $"{column.ExposedName} desc"; + } + + private static string OperationName(string name) + { + var chars = name.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_').ToArray(); + return new string(chars); + } + private static string KeyTemplate(TableModel table) => table.PrimaryKey.Count == 1 ? "{id}" : string.Join(",", table.PrimaryKey.Select(k => $"{k}={{{k}}}")); @@ -289,7 +456,7 @@ private static string KeyTemplate(TableModel table) => ["schema"] = new JsonObject { ["type"] = type }, }; - private static JsonObject OkArrayResponse(TableModel table) => new() + private static JsonObject OkArrayResponse(TableModel table, string rowsProperty) => new() { ["200"] = new JsonObject { @@ -303,7 +470,7 @@ private static string KeyTemplate(TableModel table) => ["type"] = "object", ["properties"] = new JsonObject { - ["value"] = new JsonObject + [rowsProperty] = new JsonObject { ["type"] = "array", ["items"] = Ref($"#/components/schemas/{table.ExposedName}"), @@ -315,9 +482,9 @@ private static string KeyTemplate(TableModel table) => }, }; - private static JsonObject JsonResponse(string schemaRef) => new() + private static JsonObject JsonResponse(string schemaRef, string description = "Success") => new() { - ["description"] = "Success", + ["description"] = description, ["content"] = new JsonObject { ["application/json"] = new JsonObject { ["schema"] = Ref(schemaRef) }, diff --git a/tests/EzOdata.ConformanceTests/OpenApiDocsTests.cs b/tests/EzOdata.ConformanceTests/OpenApiDocsTests.cs index 32917da..2760923 100644 --- a/tests/EzOdata.ConformanceTests/OpenApiDocsTests.cs +++ b/tests/EzOdata.ConformanceTests/OpenApiDocsTests.cs @@ -47,6 +47,43 @@ public async Task Rest_openapi_uses_table_paths() Assert.True(paths.TryGetProperty("/_table/customers/{id}", out _)); } + [Fact] + public async Task Openapi_is_structured_for_swagger_ui() + { + var doc = await _fixture.Client.GetFromJsonAsync( + $"/api/odata/{ConformanceFixture.ServiceName}/openapi.json"); + + // A header that explains how to query, and one group (tag) per table + Assert.Contains("$filter", doc.GetProperty("info").GetProperty("description").GetString()); + var tags = doc.GetProperty("tags").EnumerateArray().ToDictionary(t => t.GetProperty("name").GetString()!, t => t.GetProperty("description").GetString()!); + Assert.Contains("columns", tags["customers"]); + + var paths = doc.GetProperty("paths"); + var list = paths.GetProperty("/customers").GetProperty("get"); + Assert.Equal("customers", list.GetProperty("tags")[0].GetString()); + Assert.Equal("customers_list", list.GetProperty("operationId").GetString()); + Assert.Equal("Query customers", list.GetProperty("summary").GetString()); + Assert.Equal("Create a row in customers", paths.GetProperty("/customers").GetProperty("post").GetProperty("summary").GetString()); + + // By-key paths declare their path parameter, so "Try it out" has an input for it + var key = paths.GetProperty("/customers({id})").GetProperty("parameters")[0]; + Assert.Equal("id", key.GetProperty("name").GetString()); + Assert.Equal("path", key.GetProperty("in").GetString()); + Assert.True(key.GetProperty("required").GetBoolean()); + } + + [Fact] + public async Task Rest_openapi_documents_the_resource_envelope() + { + var doc = await _fixture.Client.GetFromJsonAsync( + $"/api/rest/{ConformanceFixture.ServiceName}/openapi.json"); + + var ok = doc.GetProperty("paths").GetProperty("/_table/customers").GetProperty("get") + .GetProperty("responses").GetProperty("200").GetProperty("content").GetProperty("application/json").GetProperty("schema"); + Assert.True(ok.GetProperty("properties").TryGetProperty("resource", out _)); + Assert.Equal("id", doc.GetProperty("paths").GetProperty("/_table/customers/{id}").GetProperty("parameters")[0].GetProperty("name").GetString()); + } + [Fact] public async Task Openapi_is_etag_cached() { From e6e9072c56a1532ec9f2c0f1346778641826cb1a Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 13:26:42 -0500 Subject: [PATCH 2/3] feat(aspnetcore): UseEzODataSwaggerUI() - a styled Swagger UI in one line Serves Swagger UI (default /swagger) listing every service declared with AddService on every API mapped with MapEzOData/MapEzODataRest, discovered at request time. Try it out on by default, table filter, request timings, deep links, schemas section hidden, and ez-odata styling (versioned stylesheet, responsive). Uses Swashbuckle.AspNetCore.SwaggerUI's embedded assets, so it works offline. --- .../Embedded/ServiceCollectionExtensions.cs | 1 + src/EzOdata.AspNetCore/EzODataEndpoints.cs | 2 + .../EzOdata.AspNetCore.csproj | 6 + .../SwaggerUI/EzODataSwaggerUI.cs | 135 ++++++++++++++++++ .../SwaggerUI/ez-odata-swagger.css | 66 +++++++++ src/EzOdata.Embedded/InMemoryStores.cs | 3 + .../EmbeddedHostTests.cs | 18 +++ 7 files changed, 231 insertions(+) create mode 100644 src/EzOdata.AspNetCore/SwaggerUI/EzODataSwaggerUI.cs create mode 100644 src/EzOdata.AspNetCore/SwaggerUI/ez-odata-swagger.css diff --git a/src/EzOdata.AspNetCore/Embedded/ServiceCollectionExtensions.cs b/src/EzOdata.AspNetCore/Embedded/ServiceCollectionExtensions.cs index 863c82a..57bc61a 100644 --- a/src/EzOdata.AspNetCore/Embedded/ServiceCollectionExtensions.cs +++ b/src/EzOdata.AspNetCore/Embedded/ServiceCollectionExtensions.cs @@ -49,6 +49,7 @@ public static IServiceCollection AddEzOData(this IServiceCollection services, Ac } services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(new ConnectorRegistry(builder.Connectors)); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EzOdata.AspNetCore/EzODataEndpoints.cs b/src/EzOdata.AspNetCore/EzODataEndpoints.cs index 6f91666..8b7c9e7 100644 --- a/src/EzOdata.AspNetCore/EzODataEndpoints.cs +++ b/src/EzOdata.AspNetCore/EzODataEndpoints.cs @@ -37,6 +37,7 @@ internal static RequestIdentity ResolveIdentity(HttpContext context) => public static IEndpointConventionBuilder MapEzOData(this IEndpointRouteBuilder endpoints, string prefix = "/api/odata") { var trimmed = "/" + prefix.Trim('/'); + endpoints.ServiceProvider.GetService()?.Add(trimmed, EzODataApi.OData); return endpoints.Map(trimmed + "/{service}/{**odataPath}", HandleAsync); } @@ -44,6 +45,7 @@ public static IEndpointConventionBuilder MapEzOData(this IEndpointRouteBuilder e public static IEndpointConventionBuilder MapEzODataRest(this IEndpointRouteBuilder endpoints, string prefix = "/api/rest") { var trimmed = "/" + prefix.Trim('/'); + endpoints.ServiceProvider.GetService()?.Add(trimmed, EzODataApi.Rest); // openapi.json served by the OData handler's docs generator (REST dialect) endpoints.Map(trimmed + "/{service}/openapi.json", async context => { diff --git a/src/EzOdata.AspNetCore/EzOdata.AspNetCore.csproj b/src/EzOdata.AspNetCore/EzOdata.AspNetCore.csproj index b5389be..998f2a1 100644 --- a/src/EzOdata.AspNetCore/EzOdata.AspNetCore.csproj +++ b/src/EzOdata.AspNetCore/EzOdata.AspNetCore.csproj @@ -6,8 +6,14 @@ ASP.NET Core host adapter: maps HTTP ↔ the host-agnostic engine contract (spec 02 §5.2). + + + + + + diff --git a/src/EzOdata.AspNetCore/SwaggerUI/EzODataSwaggerUI.cs b/src/EzOdata.AspNetCore/SwaggerUI/EzODataSwaggerUI.cs new file mode 100644 index 0000000..ba1f176 --- /dev/null +++ b/src/EzOdata.AspNetCore/SwaggerUI/EzODataSwaggerUI.cs @@ -0,0 +1,135 @@ +using System.Reflection; +using EzOdata.Embedded; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerUI; + +namespace EzOdata.AspNetCore; + +/// Which ez-odata API a mapped prefix serves. +public enum EzODataApi +{ + /// MapEzOData: OData v4. + OData, + + /// MapEzODataRest: the REST/JSON dialect. + Rest, +} + +/// Route prefixes registered by MapEzOData / MapEzODataRest, for discovery (e.g. Swagger UI). +public sealed class EzODataEndpointCatalog +{ + private readonly List<(string Prefix, EzODataApi Api)> _entries = []; + + /// Every mapped (prefix, API) pair, in mapping order. + public IReadOnlyList<(string Prefix, EzODataApi Api)> Entries + { + get { lock (_entries) return _entries.ToList(); } + } + + internal void Add(string prefix, EzODataApi api) + { + lock (_entries) + { + if (!_entries.Contains((prefix, api))) _entries.Add((prefix, api)); + } + } +} + +/// Options for . +public sealed class EzODataSwaggerUIOptions +{ + /// Where the page is served. Default swagger (→ /swagger). + public string RoutePrefix { get; set; } = "swagger"; + + /// Browser tab title. + public string DocumentTitle { get; set; } = "ez-odata API"; + + /// Services to list. Empty (default): every service declared with AddService. + public IList Services { get; } = new List(); + + /// + /// Prefixes to use when none were recorded by MapEzOData/MapEzODataRest (for example when the host + /// maps endpoints without AddEzOData). Default: /api/odata (OData). + /// + public IList<(string Prefix, EzODataApi Api)> FallbackPrefixes { get; } = new List<(string, EzODataApi)> { ("/api/odata", EzODataApi.OData) }; + + /// Escape hatch for any other Swagger UI setting. + public Action? ConfigureSwaggerUI { get; set; } +} + +/// A ready-to-use, branded Swagger UI over the OpenAPI documents ez-odata generates. +public static class EzODataSwaggerUIExtensions +{ + /// + /// Serves Swagger UI (default /swagger) listing every declared service on every mapped ez-odata API, with + /// "Try it out" on by default, a table filter, request timings and ez-odata styling. The OpenAPI documents are + /// served with the same authorization as the data, so the page shows what the caller's role allows. + /// + /// Consider enabling it only in non-production environments, or behind your own authorization. + public static IApplicationBuilder UseEzODataSwaggerUI(this IApplicationBuilder app, Action? configure = null) + { + var options = new EzODataSwaggerUIOptions(); + configure?.Invoke(options); + var prefix = options.RoutePrefix.Trim('/'); + var stylesheet = $"/{prefix}/ez-odata-swagger.css"; + // Versioned URL: cached per release, never stale after an upgrade. + var version = typeof(EzODataSwaggerUIExtensions).Assembly + .GetCustomAttribute()?.InformationalVersion?.Split('+')[0] ?? "1"; + + var css = ReadStylesheet(); + app.Map(stylesheet, branch => branch.Run(async context => + { + context.Response.ContentType = "text/css; charset=utf-8"; + context.Response.Headers.CacheControl = "public, max-age=3600"; + await context.Response.WriteAsync(css); + })); + + var services = app.ApplicationServices; + return app.UseSwaggerUI(ui => + { + ui.RoutePrefix = prefix; + ui.DocumentTitle = options.DocumentTitle; + ui.ConfigObject.Urls = Documents(services, options); // enumerated per request: mapping order doesn't matter + ui.DocExpansion(DocExpansion.List); + ui.DefaultModelsExpandDepth(-1); // request/response schemas are shown on each operation + ui.EnableFilter(); // search box: filter operations by table + ui.EnableTryItOutByDefault(); + ui.DisplayRequestDuration(); + ui.EnableDeepLinking(); + ui.ShowCommonExtensions(); + ui.InjectStylesheet($"{stylesheet}?v={Uri.EscapeDataString(version)}"); + options.ConfigureSwaggerUI?.Invoke(ui); + }); + } + + private static IEnumerable Documents(IServiceProvider services, EzODataSwaggerUIOptions options) + { + var catalog = services.GetService(); + var prefixes = catalog?.Entries is { Count: > 0 } recorded ? recorded : options.FallbackPrefixes.ToList(); + var names = options.Services.Count > 0 + ? options.Services.ToList() + : services.GetService()?.ServiceNames ?? []; + + foreach (var name in names) + { + foreach (var (apiPrefix, api) in prefixes.OrderBy(p => p.Api)) + { + yield return new UrlDescriptor + { + Url = $"{apiPrefix}/{Uri.EscapeDataString(name)}/openapi.json", + Name = $"{name} · {(api == EzODataApi.OData ? "OData v4" : "REST")}", + }; + } + } + } + + private static string ReadStylesheet() + { + using var stream = typeof(EzODataSwaggerUIExtensions).Assembly.GetManifestResourceStream("EzOdata.AspNetCore.ez-odata-swagger.css") + ?? throw new InvalidOperationException("Embedded Swagger UI stylesheet is missing."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/EzOdata.AspNetCore/SwaggerUI/ez-odata-swagger.css b/src/EzOdata.AspNetCore/SwaggerUI/ez-odata-swagger.css new file mode 100644 index 0000000..67f1cb5 --- /dev/null +++ b/src/EzOdata.AspNetCore/SwaggerUI/ez-odata-swagger.css @@ -0,0 +1,66 @@ +/* ez-odata styling for Swagger UI: palette from the ez-odata admin console. */ +:root { + --ez-bg: #0f1115; + --ez-surface: #1a1d24; + --ez-accent: #4c8dff; + --ez-accent-strong: #2f6fe0; + --ez-text: #1a1d24; + --ez-muted: #5a6270; +} + +body { background: #f5f6f8; } +.swagger-ui { color: var(--ez-text); } +.swagger-ui .wrapper { max-width: 1320px; } + +/* Top bar: ez-odata wordmark instead of the Swagger logo */ +.swagger-ui .topbar { background: var(--ez-bg); padding: 12px 0; border-bottom: 3px solid var(--ez-accent); } +.swagger-ui .topbar .link { display: flex; align-items: center; } +.swagger-ui .topbar .link img, .swagger-ui .topbar .link svg { display: none; } +.swagger-ui .topbar .link::before { + content: "ez-odata"; color: #fff; font: 700 22px/1 system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: -0.02em; +} +.swagger-ui .topbar .link::after { + content: "API explorer"; color: var(--ez-accent); font: 500 14px/1 system-ui, -apple-system, "Segoe UI", sans-serif; margin-left: 10px; +} +.swagger-ui .topbar .download-url-wrapper .select-label { color: #c9ced6; } +.swagger-ui .topbar .download-url-wrapper .select-label span { font-weight: 500; } +.swagger-ui .topbar .download-url-wrapper .select-label select, +.swagger-ui .topbar .download-url-wrapper select { + border: 1px solid #3a4150 !important; border-radius: 8px; background-color: var(--ez-surface); color: #e6e8ec; + font-weight: 600; min-width: 260px; box-shadow: none; +} +.swagger-ui .topbar .download-url-wrapper .select-label select:focus { border-color: var(--ez-accent) !important; outline: none; } + +/* Header */ +.swagger-ui .info { margin: 32px 0 16px; } +.swagger-ui .info .title { font-weight: 700; letter-spacing: -0.02em; color: var(--ez-text); } +.swagger-ui .info .title small { background: var(--ez-muted); } +.swagger-ui .info .title small.version-stamp { background: var(--ez-accent); } +.swagger-ui .info .description p, .swagger-ui .info .description td { font-size: 14px; color: #394150; } +.swagger-ui .info .description table { border-collapse: collapse; margin: 8px 0 12px; width: auto; } +@media (min-width: 720px) { .swagger-ui .info .description table { min-width: 520px; } } +@media (max-width: 719px) { + .swagger-ui .info .description table { display: block; max-width: 100%; overflow-x: auto; } + .swagger-ui .topbar .download-url-wrapper .select-label select { min-width: 0; width: 100%; } +} +.swagger-ui .info .description th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ez-muted); } +.swagger-ui .info .description th, .swagger-ui .info .description td { padding: 6px 12px; border-bottom: 1px solid #e3e6ea; text-align: left; } +.swagger-ui .info .description code { background: #eef0f3; border-radius: 4px; padding: 1px 5px; color: #1f2937; } +.swagger-ui .info a, .swagger-ui a.nostyle { color: var(--ez-accent-strong); } +.swagger-ui .scheme-container { background: #fff; box-shadow: none; border-top: 1px solid #e3e6ea; border-bottom: 1px solid #e3e6ea; } + +/* Table groups */ +.swagger-ui .opblock-tag { border-bottom: 1px solid #e3e6ea; font-weight: 700; letter-spacing: -0.01em; } +.swagger-ui .opblock-tag small { color: var(--ez-muted); font-weight: 400; } +.swagger-ui .opblock { border-radius: 8px; box-shadow: none; } +.swagger-ui .opblock .opblock-summary { border-radius: 8px; } +.swagger-ui .opblock .opblock-summary-method { border-radius: 6px; min-width: 76px; } +.swagger-ui .filter-container .operation-filter-input { border: 1px solid #d7dbe0; border-radius: 8px; padding: 10px 12px; } + +/* Actions */ +.swagger-ui .btn.execute { background: var(--ez-accent); border-color: var(--ez-accent); border-radius: 6px; } +.swagger-ui .btn.execute:hover { background: var(--ez-accent-strong); } +.swagger-ui .btn.authorize { color: var(--ez-accent-strong); border-color: var(--ez-accent); border-radius: 6px; } +.swagger-ui .btn.authorize svg { fill: var(--ez-accent-strong); } +.swagger-ui input[type=text], .swagger-ui textarea, .swagger-ui select { border-radius: 6px; } +.swagger-ui .responses-inner h4, .swagger-ui .responses-inner h5 { color: var(--ez-text); } diff --git a/src/EzOdata.Embedded/InMemoryStores.cs b/src/EzOdata.Embedded/InMemoryStores.cs index 728c06c..e314c94 100644 --- a/src/EzOdata.Embedded/InMemoryStores.cs +++ b/src/EzOdata.Embedded/InMemoryStores.cs @@ -24,6 +24,9 @@ public InMemoryServiceRuntimeResolver(IReadOnlyList d _connectors = connectors; } + /// Names of the services declared with AddService, in declaration order. + public IReadOnlyList ServiceNames => _definitions.Select(d => d.Name).ToList(); + public Task ResolveAsync(string serviceName, CancellationToken ct) => Task.FromResult(_runtimes.TryGetValue(serviceName, out var runtime) ? runtime : null); diff --git a/tests/EzOdata.IntegrationTests/EmbeddedHostTests.cs b/tests/EzOdata.IntegrationTests/EmbeddedHostTests.cs index bd7ce54..a878676 100644 --- a/tests/EzOdata.IntegrationTests/EmbeddedHostTests.cs +++ b/tests/EzOdata.IntegrationTests/EmbeddedHostTests.cs @@ -72,6 +72,7 @@ INSERT INTO widgets (name, secret, region) VALUES e.MapEzOData("/api/odata"); e.MapEzODataRest("/api/rest"); }); + app.UseEzODataSwaggerUI(); }); }) .StartAsync(); @@ -127,6 +128,23 @@ public async Task Embedded_openapi_server_urls_point_at_the_service() } } + [Fact] + public async Task Swagger_ui_lists_every_service_on_every_mapped_api() + { + var page = await _client.GetAsync("/swagger/index.html"); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Contains("ez-odata-swagger.css", await page.Content.ReadAsStringAsync()); + + // Discovered from AddService + MapEzOData/MapEzODataRest, no configuration needed + var config = await _client.GetStringAsync("/swagger/index.js"); + Assert.Contains("/api/odata/inventory/openapi.json", config); + Assert.Contains("/api/rest/inventory/openapi.json", config); + + var css = await _client.GetAsync("/swagger/ez-odata-swagger.css"); + Assert.Equal(HttpStatusCode.OK, css.StatusCode); + Assert.Equal("text/css", css.Content.Headers.ContentType?.MediaType); + } + /// Test auth handler that grants the 'reader' role to every request. private sealed class TestAuthHandler : AuthenticationHandler { From d56e15e51bdf9838794447b2c7b946051c6f39d5 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 13:26:42 -0500 Subject: [PATCH 3/3] chore(release): 1.0.7 --- Directory.Build.props | 2 +- js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 751e6c4..040d319 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,7 +13,7 @@ netstandard2.0 net10.0 - 1.0.6 + 1.0.7 Noctusoft, Inc. Noctusoft, Inc. Copyright © 2026 Noctusoft, Inc. diff --git a/js/package.json b/js/package.json index 0389b0d..1891138 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "@noctusoft/ezodata-express", - "version": "1.0.6", + "version": "1.0.7", "description": "Bolt a governed OData v4 + REST + MCP API onto any Express app. Pure TypeScript, full parity with the ez-odata .NET engine.", "repository": { "type": "git",