Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<EzEngineTfm>netstandard2.0</EzEngineTfm>
<EzPlatformTfm>net10.0</EzPlatformTfm>
<!-- NuGet metadata for the embeddable packages (spec 15 §2) -->
<Version>1.0.6</Version>
<Version>1.0.7</Version>
<Authors>Noctusoft, Inc.</Authors>
<Company>Noctusoft, Inc.</Company>
<Copyright>Copyright © 2026 Noctusoft, Inc.</Copyright>
Expand Down
2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public static IServiceCollection AddEzOData(this IServiceCollection services, Ac
}

services.AddSingleton<ISystemClock, SystemClock>();
services.AddSingleton<EzODataEndpointCatalog>();
services.AddSingleton<IConnectorRegistry>(new ConnectorRegistry(builder.Connectors));
services.AddSingleton<EdmModelFactory>();
services.AddSingleton<PolicyEngine>();
Expand Down
2 changes: 2 additions & 0 deletions src/EzOdata.AspNetCore/EzODataEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ internal static RequestIdentity ResolveIdentity(HttpContext context) =>
public static IEndpointConventionBuilder MapEzOData(this IEndpointRouteBuilder endpoints, string prefix = "/api/odata")
{
var trimmed = "/" + prefix.Trim('/');
endpoints.ServiceProvider.GetService<EzODataEndpointCatalog>()?.Add(trimmed, EzODataApi.OData);

return endpoints.Map(trimmed + "/{service}/{**odataPath}", HandleAsync);
}

public static IEndpointConventionBuilder MapEzODataRest(this IEndpointRouteBuilder endpoints, string prefix = "/api/rest")
{
var trimmed = "/" + prefix.Trim('/');
endpoints.ServiceProvider.GetService<EzODataEndpointCatalog>()?.Add(trimmed, EzODataApi.Rest);
// openapi.json served by the OData handler's docs generator (REST dialect)
endpoints.Map(trimmed + "/{service}/openapi.json", async context =>
{
Expand Down
6 changes: 6 additions & 0 deletions src/EzOdata.AspNetCore/EzOdata.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
<Description>ASP.NET Core host adapter: maps HTTP ↔ the host-agnostic engine contract (spec 02 §5.2).</Description>
</PropertyGroup>

<ItemGroup>
<EmbeddedResource Include="SwaggerUI/ez-odata-swagger.css" LogicalName="EzOdata.AspNetCore.ez-odata-swagger.css" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<!-- UseEzODataSwaggerUI(): Swagger UI assets are embedded in this package (works offline) -->
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />
<ProjectReference Include="..\EzOdata.Core\EzOdata.Core.csproj" />
<ProjectReference Include="..\EzOdata.OData\EzOdata.OData.csproj" />
<ProjectReference Include="..\EzOdata.Rest\EzOdata.Rest.csproj" />
Expand Down
135 changes: 135 additions & 0 deletions src/EzOdata.AspNetCore/SwaggerUI/EzODataSwaggerUI.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Which ez-odata API a mapped prefix serves.</summary>
public enum EzODataApi
{
/// <summary><c>MapEzOData</c>: OData v4.</summary>
OData,

/// <summary><c>MapEzODataRest</c>: the REST/JSON dialect.</summary>
Rest,
}

/// <summary>Route prefixes registered by <c>MapEzOData</c> / <c>MapEzODataRest</c>, for discovery (e.g. Swagger UI).</summary>
public sealed class EzODataEndpointCatalog
{
private readonly List<(string Prefix, EzODataApi Api)> _entries = [];

/// <summary>Every mapped (prefix, API) pair, in mapping order.</summary>
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));
}
}
}

/// <summary>Options for <see cref="EzODataSwaggerUIExtensions.UseEzODataSwaggerUI"/>.</summary>
public sealed class EzODataSwaggerUIOptions
{
/// <summary>Where the page is served. Default <c>swagger</c> (→ <c>/swagger</c>).</summary>
public string RoutePrefix { get; set; } = "swagger";

/// <summary>Browser tab title.</summary>
public string DocumentTitle { get; set; } = "ez-odata API";

/// <summary>Services to list. Empty (default): every service declared with <c>AddService</c>.</summary>
public IList<string> Services { get; } = new List<string>();

/// <summary>
/// Prefixes to use when none were recorded by <c>MapEzOData</c>/<c>MapEzODataRest</c> (for example when the host
/// maps endpoints without <c>AddEzOData</c>). Default: <c>/api/odata</c> (OData).
/// </summary>
public IList<(string Prefix, EzODataApi Api)> FallbackPrefixes { get; } = new List<(string, EzODataApi)> { ("/api/odata", EzODataApi.OData) };

/// <summary>Escape hatch for any other Swagger UI setting.</summary>
public Action<SwaggerUIOptions>? ConfigureSwaggerUI { get; set; }
}

/// <summary>A ready-to-use, branded Swagger UI over the OpenAPI documents ez-odata generates.</summary>
public static class EzODataSwaggerUIExtensions
{
/// <summary>
/// Serves Swagger UI (default <c>/swagger</c>) 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.
/// </summary>
/// <remarks>Consider enabling it only in non-production environments, or behind your own authorization.</remarks>
public static IApplicationBuilder UseEzODataSwaggerUI(this IApplicationBuilder app, Action<EzODataSwaggerUIOptions>? 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<AssemblyInformationalVersionAttribute>()?.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<UrlDescriptor> Documents(IServiceProvider services, EzODataSwaggerUIOptions options)
{
var catalog = services.GetService<EzODataEndpointCatalog>();
var prefixes = catalog?.Entries is { Count: > 0 } recorded ? recorded : options.FallbackPrefixes.ToList();
var names = options.Services.Count > 0
? options.Services.ToList()
: services.GetService<InMemoryServiceRuntimeResolver>()?.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();
}
}
66 changes: 66 additions & 0 deletions src/EzOdata.AspNetCore/SwaggerUI/ez-odata-swagger.css
Original file line number Diff line number Diff line change
@@ -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); }
Loading
Loading