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",
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.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/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.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()
{
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
{