Skip to content

RESTier vNext: Modernize to ASP.NET Core, OData 8.x, and .NET 8/9/10#776

Open
jspuij wants to merge 613 commits into
OData:devfrom
jspuij:feature/vnext
Open

RESTier vNext: Modernize to ASP.NET Core, OData 8.x, and .NET 8/9/10#776
jspuij wants to merge 613 commits into
OData:devfrom
jspuij:feature/vnext

Conversation

@jspuij
Copy link
Copy Markdown
Contributor

@jspuij jspuij commented Apr 19, 2026

Summary

This PR is the cumulative result of the RESTier vNext effort, shipping as 2.0.0-beta — a substantial rework on top of the 1.2 baseline. The headline themes:

  • A single options-bag (RestierRouteOptions) for all per-route configuration.
  • Endpoint routing is now the only routing model — the legacy convention-based pipeline is gone.
  • Magical operations[BoundOperation] / [UnboundOperation] methods register themselves.
  • Keyless EF views become first-class read-only resources with full pipeline integration.
  • OpenAPI annotations are emitted automatically from standard .NET attributes.
  • New optional packages for API versioning, NSwag/ReDoc, and spatial types.
  • Deep insert / deep update / @odata.bind, multi-tenancy, and lower-camelCase JSON.

Full release notes: src/Microsoft.Restier.Docs/release-notes/2-0-0-beta.md. Full diff: v1.2.0...feature/vnext.

Platform updates

  • Target frameworks: net8.0, net9.0, net10.0. .NET Framework 4.8 is no longer supported. Restier 1.x is still maintained for .NET Framework consumers.
  • OData stack: Microsoft.OData.Core / Microsoft.OData.Edm 8.x, Microsoft.AspNetCore.OData 9.x, Microsoft.OData.ModelBuilder 2.x.
  • EF Core: 8.x, 9.x, and 10.x. EF6: 6.5.x.
  • Test stack: MSTest + FluentAssertions (AwesomeAssertions) + NSubstitute.

Breaking changes

AddRestierRoute overloads collapsed to the options-bag form

The old AddRestierRoute overload set (taking individual Action<IServiceCollection> and validation knobs) has been replaced with a single options-bag form:

options.AddRestierRoute<NorthwindApi>(
    "api",
    routeServices => routeServices.AddEFCoreProviderServices<NorthwindContext>(...),
    bag =>
    {
        bag.NamingConvention = RestierNamingConvention.CamelCase;
        bag.Validation.MaxExpansionDepth = 3;
        bag.Conformance.StrictMissingParentForCollections = true;
        bag.DeepOperations.MaxDepth = 4;
    });

The bag exposes DeepOperations, Conformance, Validation, UseRestierBatching, and NamingConvention. The versioning package's AddVersion was updated to the same shape.

Endpoint routing only — MapRestier() replaces legacy conventions

The old convention-based routing infrastructure (RestierRouteConvention, RestierControllerRouteConvention, …) has been deleted. Routes are now registered through ASP.NET Core endpoint routing via endpoints.MapRestier(), which wires a RestierRouteValueTransformer that dynamically parses OData paths and dispatches into RestierController. The marker type RestierRouteMarker identifies route containers as Restier-owned.

Query validation: bag-only, no more DI registration of ODataValidationSettings

Per-route query validation knobs (MaxTop, MaxSkip, MaxExpansionDepth, MaxAnyAllExpressionDepth, MaxOrderByNodeCount, MaxNodeCount) now live on RestierRouteOptions.Validation — and the bag is the only configuration channel.

  • ODataValidationSettings is no longer a route-DI service. Consumers must resolve RestierValidationOptions from the route container instead.
  • DI registration of ODataValidationSettings inside the AddRestierRoute service callback throws InvalidOperationException at startup with a migration message.

The only place MaxTop can still appear twice — bag and global ODataOptions.SetMaxTop(...) — emits a Trace.TraceWarning if the two values disagree. Closes #684, #719, #751.

OperationContext.GetParameterValueFunc is now presence-aware

Changed from Func<string, object> to Func<string, (bool Present, object Value)>. The Present flag is true when the parameter name appears in the request, even if the supplied value is null — necessary to distinguish "URL omitted the parameter" from "URL supplied p=null" for default substitution and explicit-null semantics on the same parameter.

Affects custom RestierController subclasses that construct their own getParaValueFunc, and any code that constructs OperationContext directly.

GET queries no longer change-track entities (rolled forward from 1.2)

GET queries execute with change tracking disabled by default (EF Core: AsNoTrackingWithIdentityResolution; EF6: AsNoTracking with a cycle-aware fallback). The submit pipeline and internal lookups are unaffected. Opt back in via restierOpts.TrackingBehavior = RestierEFTrackingBehavior.TrackAll. IExpandCycleDetector is now a first-class core service and the controller computes a HasRecursiveExpand hint on QueryRequest.

Magical operations

[BoundOperation] / [UnboundOperation]-decorated methods are now fully self-registering. Highlights:

  • Complex types auto-registered as ComplexType, EntityType (when keyed), or EnumType without manual model-builder work (#651).
  • Optional parameters from four signal sources — Nullable<T>, compiler defaults, [DefaultValue], and the new [Optional] attribute — produce the correct EdmOptionalParameter shape and default literal. The runtime substitutes declared defaults on URL-omitted parameters; explicit ?p=null on a nullable parameter passes null. Non-nullable value-type parameters without [Optional] or [DefaultValue] are rejected at startup (#656).
  • Duplicate-name detection — manual + [Operation] registration no longer creates an EDM duplicate; the manual registration wins and a Trace.TraceWarning surfaces the conflict (#652).
  • [Obsolete] on a method emits Core.V1.Revisions { Kind = Deprecated }, round-tripping into OpenAPI's deprecated field.
  • Parameter-level [Description] annotates EdmOperationParameter with Core.V1.Description.

Closes the operation-related items of #750.

Keyless views

Auto-generated keyless-view function imports flow through the normal RESTier query pipeline:

  • Keyless EF Core entities are demoted to EdmComplexType and surfaced as a FunctionImport (GET /Service/MyView()).
  • The function-import handler dispatches through a registry built at model time, with a per-route IQueryExpressionSourcer projecting the keyless type.
  • OnFilter<View> convention methods fire. Visibility is protected or protected internal, matching the entity-set contract.
  • Custom IQueryExpressionAuthorizer registrations see view GET requests.
  • RestierEFOptions.TrackingBehavior applies to keyless-view reads.
  • DELETE / PUT / PATCH on a function import returns 405 Method Not Allowed.

Convention name corrected from the V1 docs' OnFiltering<View> to OnFilter<View> (matches the entity-set convention via ConventionBasedMethodNameFactory.GetEntitySetMethodName). IOperationFilter does not fire for view requests — use IQueryExpressionProcessor or OnFilter<View>. Closes #741.

Authorization metadata on API surfaces

[AllowAnonymous], [Authorize], [Authorize(Policy = …)], [Authorize(Roles = …)], and [Authorize(AuthenticationSchemes = …)] are now honored on:

  • The ApiBase subclass itself (scoping every route the API serves).
  • Individual operation methods on the API.

A new RestierAuthorizationMetadataPolicy propagates the discovered attributes onto the matched endpoint so AuthorizationMiddleware runs against them before the request reaches RESTier. RESTier-level convention authorization (Can{Operation}{Target}, IChangeSetItemAuthorizer) continues to layer on top.

API versioning — new package: Microsoft.Restier.AspNetCore.Versioning

URL-segment API versioning built on Asp.Versioning. Each version is a distinct ApiBase subclass at its own route prefix, with its own EDM, $metadata, and OpenAPI document.

  • RestierApiVersionSegmentFormatters (Major, MajorMinor) control how ApiVersion becomes a URL segment.
  • [ApiVersion] attributes on each API class are discovered by ApiVersionAttributeReader.
  • UseRestierVersionHeaders middleware emits api-supported-versions and api-deprecated-versions response headers.
  • NSwag and Swagger doc resolution is registry-aware — each version gets its own OpenAPI document and dropdown entry.
  • Versioned $batch routing is supported.
  • Optional Sunset and explicit base-prefix overrides.

NSwag integration — new package: Microsoft.Restier.AspNetCore.NSwag

A first-class NSwag integration that ports the Restier OpenAPI document generator onto the NSwag pipeline:

  • AddRestierNSwag(settings => …) service registration.
  • UseRestierOpenApi() middleware serving the OpenAPI document at /openapi/v1.json (path configurable; honors Sunset).
  • UseRestierReDoc() and UseRestierNSwagUI() for ReDoc and the NSwag UI, both registry-aware and listing user-registered NSwag documents in the dropdown.
  • RestierControllerApiExplorerConvention excludes RestierController from plain MVC ApiExplorer discovery — hand-written controllers stay isolated from the Restier doc.

NSwag is the recommended integration for new projects; the Swashbuckle-based Microsoft.Restier.AspNetCore.Swagger package remains supported.

OpenAPI annotations from .NET attributes

Restier scans CLR types for standard .NET attributes and emits the matching OData vocabulary annotations into $metadata. NSwag and Swagger surface them in the generated OpenAPI document with no extra configuration:

Attribute EDM annotation OpenAPI effect
[Description] Core.V1.Description Schema / property / operation description
[DatabaseGenerated] Core.V1.Computed Property dropped from POST/PATCH/PUT bodies; readOnly
[ReadOnly(true)] Core.V1.Immutable Property dropped from PATCH/PUT bodies (POST still accepts)
[Obsolete] (operation) Core.V1.Revisions { Kind = Deprecated } deprecated: true
[Range] Validation.Min / Validation.Max minimum / maximum
[RegularExpression] Validation.Pattern pattern

Core.V1.Computed and Core.V1.Immutable are not metadata-only — the submit pipeline reads them to drop request-body properties before the change set is applied.

Spatial types — new packages

Round-tripping Microsoft.Spatial types through EF6 and EF Core, plus server-side translation of OData geo.* filter functions:

  • Microsoft.Restier.EntityFramework.Spatial wires Microsoft.Spatial to System.Data.Entity.Spatial.DbGeography/DbGeometry via DbSpatialConverter and DbSpatialModelMetadataProvider. Register with services.AddRestierSpatial().
  • Microsoft.Restier.EntityFrameworkCore.Spatial wires Microsoft.Spatial to NetTopologySuite via NtsSpatialConverter and column-type inference (NtsSpatialModelMetadataProvider).
  • [Spatial] opts CLR properties into the EDM Geography/Geometry primitive types.
  • RestierSpatialFilterBinder translates geo.intersects, geo.distance, and geo.length to provider methods/properties so EF Core can push them to the database.
  • SridPrefixHelpers mediates the SQL Server WKT dialect (SRID=4326;…) when needed.
  • ODataOptions.TimeZone is propagated to the filter binder (fixes #704).

Closes #673.

Multi-tenancy guide and middleware support

Restier's per-route scoped DI plus EF Core's runtime DbContextOptions configuration are enough to build a DB-per-tenant SaaS service from one ApiBase subclass:

  • A PathSegmentTenantResolutionMiddleware reads the tenant id from the URL, validates it against an IConnectionStringProvider, and populates a scoped ITenantContext.
  • The route's AddDbContext factory bridges back via IHttpContextAccessor to pick the right connection string at request time.
  • @odata.context preserves the tenant prefix via PathBase.

No changes to RESTier itself are required — this is shipped as a guide with an end-to-end integration test fixture.

Deep insert / deep update / @odata.bind

POST and PATCH bodies can now express nested writes:

  • DeepOperationExtractor walks the request payload into a DataModificationItem tree (with BindReference nodes for @odata.bind).
  • DeepUpdateClassifier decides — per nested property — whether to insert, update, link, or unlink (with FK update / relationship removal where applicable).
  • DefaultChangeSetInitializer and the EF6/EFCore initializers handle the new tree shape and emit 400 for relationship constraint violations (DbUpdateException).
  • RestierRouteOptions.DeepOperations.MaxDepth caps nesting depth (default 5; set to 0 to disable).
  • OData-Version 4.01 is required for nested PATCH bodies; non-4.01 requests get a clear error.
  • $ContentId references in $batch change sets are resolved via the new ChangeSetDependencyResolver, with the whole batch enlisted in a TransactionScope (#762).

Lower-camelCase JSON

RestierRouteOptions.NamingConvention = RestierNamingConvention.CamelCase transforms property names end-to-end:

  • $metadata and query response payloads use camelCase property names.
  • Request bodies, ETags, and If-Match / If-None-Match headers normalize to CLR names before reaching the submit pipeline (EdmClrPropertyMapper).
  • RestierResourceDeserializer accepts camelCase enum literals and validates them against the CLR enum.

Closes #549.

Conformance toggle: StrictMissingParentForCollections

A collection-valued navigation property whose parent entity doesn't exist (e.g. /Books(missing)/Reviews) historically returned 200 OK { "value": [] }. With bag.Conformance.StrictMissingParentForCollections = true it returns 404 Not Found per OData v4 Part 1 §9.1.5 / §11.2.6, at the cost of one extra parent-existence query per request. The toggle also extends to $count (#735).

Other notable changes

  • Deferred query materialization (#614). DefaultQueryExecutor and EFQueryExecutor no longer materialize collections eagerly; the controller adds 404 detection for key-based requests against missing resources, and EFChangeSetInitializer.FindResource materializes explicitly when needed.
  • OnFilter for single navigation properties. Interceptors now fire for single navigation references inside $expand (#519).
  • OData-Version: 4.01 gating with a clear error message when a request uses a 4.01-only construct on a 4.0 endpoint.
  • $filter path segment. RestierQueryBuilder handles FilterSegment so /Books/$filter(Year gt 2000)/$count works end-to-end.
  • DateOnly / TimeOnly support added to the Restier type mapping pipeline, including provider-specific EFCore metadata baselines.
  • PostgreSQL sample (Microsoft.Restier.Samples.Postgres.AspNetCore) — a vnext-style sample wired to PostgreSQL via EF Core, including a keyless-view example.
  • Documentation rebuilt on top of the DotNetDocs SDK and Mintlify (MDX). The api-reference/ tree is regenerated from XML doc comments on build.

Testing infrastructure

  • All test projects moved from src/ to test/.
  • Removed legacy/obsolete test projects (Tests.Legacy, Tests.Breakdance, Tests.AspNet, Tests.AspNetCorePlusEF6).
  • Shared test infrastructure: Tests.Shared, Tests.Shared.EntityFramework, Tests.Shared.EntityFrameworkCore.
  • Dual EF6/EFCore testing: feature, metadata, and regression tests run against both providers via shared scenario files and helpers.
  • SQL Server required: in-memory database fallbacks removed; tests use connection strings configured via dotnet user-secrets. Thread-safe seeding prevents race conditions in parallel runs.
  • InternalsVisibleTo auto-configured from source to matching test project.

Test plan

  • dotnet build RESTier.slnx succeeds on all target frameworks (net8.0, net9.0, net10.0)
  • dotnet test RESTier.slnx — all tests pass (xUnit v3)
  • EF6 integration tests pass against SQL Server
  • EF Core integration tests pass against SQL Server (connection strings via dotnet user-secrets)
  • Naming convention tests pass for CamelCase mode
  • Keyless view integration tests pass (function-import dispatch, OnFilter<View>, 405 for write methods)
  • Deep insert / deep update / @odata.bind integration tests pass; nested PATCH requires OData-Version: 4.01
  • $ContentId reference resolution works inside $batch change sets
  • Multi-tenancy integration test fixture starts and routes per-tenant
  • API versioning sample starts and emits api-supported-versions headers
  • NSwag middleware serves OpenAPI doc at /openapi/v1.json; ReDoc and NSwag UI render
  • Swagger UI renders for the Swashbuckle-based package
  • Spatial filter functions (geo.intersects, geo.distance, geo.length) translate against EF Core + NetTopologySuite
  • Northwind sample starts and serves OData endpoints
  • PostgreSQL sample starts with migrations and seed data
  • dotnet build src/Microsoft.Restier.Docs/Microsoft.Restier.Docs.docsproj regenerates api-reference/ and docs.json

@jspuij jspuij mentioned this pull request Apr 19, 2026
@jspuij
Copy link
Copy Markdown
Contributor Author

jspuij commented Apr 19, 2026

To start the conversation. Some opinionated choices have been made regarding the service registration and the new constructor signature for ApiBase which both eliminates the ServiceLocator anti-pattern and provides better extensibility. Service chaining has been decoupled from the underlying DI container and does not use reflection anymore (but an interface) to accomplish the chaining.

The branch has switched to xUnit and FluentAssertions to better align with the other OData projects. BreakDance is still being used to setup the tests and generate API surfaces.
In addition to the old integration tests, the entire project has complete coverage with unit tests now. During generation of the tests I found several bugs that I've fixed in the mean time. They are probably scattered in the 100 commits.

Full OData.NET and AspNetCoreOdata support. dotnet 8, 9 and 10, although we are still waiting on the RTM versions of OData.NET and AspNetCoreOdata for dotnet 10, so it's forward compatibility for now.

I might add some other fixes here and there for issues to this branch, while it's being reviewed. Full disclosure: The first part of this migration was done by hand with a little help of copilot. The last month I've been using Claude Code extensively.

@robertmclaws shall we work together on this? What do you think.

@gathogojr I see that you're involved now as well. Let's discuss. I've done previous upgrades of RESTier as well.

@robertmclaws
Copy link
Copy Markdown
Collaborator

@jspuij I just dropped you an email. Let's catch up this week and talk about it.

Jan-Willem Spuij and others added 15 commits May 7, 2026 17:05
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… project

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…trip

EF6's DbGeography.FromText requires the native Microsoft.SqlServer.Types
assembly which is not available on macOS/Linux.  The three geometry-
exercising tests are guarded with xUnit v3 SkipUnless so they execute on
Windows CI (where the native types are present) and are skipped cleanly
on all other platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…RID, DbGeometry, errors)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Spatial project

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-> NTS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-type inference

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Jan-Willem Spuij and others added 28 commits May 21, 2026 14:50
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tputHelper, Assert.*)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace unconditional [TestMethod, Ignore] with [TestMethod] + Assert.Inconclusive guard
inside Round_trips_LineString and Round_trips_Polygon so the tests run (and can pass) on
machines where SqlServerSpatial160.dll is loadable, while still reporting Skipped on
machines where the native binary is absent (macOS, CI without the binary).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rTrackingTests

MSTest does not call Dispose() automatically; the method existed but was never invoked, leaving LibraryContext instances to GC. Swap to [TestCleanup] Cleanup() so the context is deterministically disposed after every test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rcerTrackingTests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…maphore

Adds SharedLocalDbLock — a named OS semaphore (Windows-only, no-op on
macOS/Linux) — and AssemblyHooks classes for the five assemblies that
share access to the LocalDB-backed LibraryContext / MarvelContext
databases (Tests.AspNetCore, Tests.AspNetCore.NSwag,
Tests.AspNetCore.Swagger, Tests.AspNetCore.Versioning,
Tests.EntityFramework). Also adds the missing Tests.Shared
ProjectReference to the three assemblies that lacked it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ootprint

These three assemblies use Microsoft.AspNetCore.TestHost in-process and
have no reference to LibraryContext, RestierTestHelpers, UseSqlServer, or
MSSQLLocalDB. The SharedLocalDbLock semaphore added in f46e65a is
unnecessary here; only Tests.AspNetCore and Tests.EntityFramework genuinely
touch LocalDB and retain their AssemblyHooks. Also removes the now-unused
Tests.Shared ProjectReference from the three csproj files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… tests

Guard Acquire() against a second call in the same process (would leak the
semaphore handle and self-deadlock via a re-entrant WaitOne on a count=1
semaphore). Add AssemblyHooks to Tests.EntityFrameworkCore, which shares the
same LibraryContext_*_EFCore LocalDB database as Tests.AspNetCore's EFCore
feature tests and must therefore also serialise via the named semaphore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
xUnit→MSTest migration follow-ups: restore conditional skips on 6 spatial
tests, replace IDisposable with [TestCleanup] in EFQuerySourcerTrackingTests
(EF6+EFCore), and add cross-process SharedLocalDbLock for the 3 assemblies
that share LocalDB databases (AspNetCore, EntityFramework, EntityFrameworkCore).

# Conflicts:
#	test/Microsoft.Restier.Tests.AspNetCore/FeatureTests/ActionTests.cs
The DotNetCoreCLI@2 test task automatically appends
--results-directory $(Agent.TempDirectory)/TestResults when
publishTestResults: true is set. Passing it explicitly in arguments
caused 'Option --results-directory expects a single argument but 2 were
provided' and failed every test job.

The auto-added path is the same one ReportGenerator's -reports: glob
expects, so removing the explicit line leaves coverage collection
unchanged.
After dropping the explicit --results-directory, the DotNetCoreCLI@2
task auto-adds its own results directory which may not be exactly
$(Agent.TempDirectory)/TestResults — so the previous narrow glob
$(Agent.TempDirectory)/TestResults/**/coverage.cobertura.xml found
nothing and ReportGenerator exited non-zero.

Broaden the glob to $(Agent.TempDirectory)/**/coverage.cobertura.xml,
and prepend a directory listing so the next failure (if any) is
diagnosable from the log.

Tests pass cleanly on the previous run (3279 passed, 0 failed, 12
skipped), so this is purely a coverage-report-discovery fix.
…'s new

Adds a "What's New in 2.0 (Beta)" section enumerating the vNext
highlights (modern targets, endpoint routing, API versioning, OpenAPI,
deep operations, keyless views, spatial types, multi-tenancy,
authorization attributes, AsNoTracking default, magical operations,
dynamic routing, deferred materialization, conformance options,
validation options, MSTest).

Updates Supported Platforms to drop Classic ASP.NET and pre-8.0 .NET
targets, calling out that the 1.x line remains on NuGet but is no
longer actively developed.

Rewrites the Restier Components list to add NSwag, Versioning, and the
two Spatial providers, and refines the existing entries for accuracy.
@jspuij jspuij marked this pull request as ready for review May 21, 2026 19:22
@jspuij
Copy link
Copy Markdown
Contributor Author

jspuij commented May 21, 2026

@robertmclaws I'm about as far as we discussed. Let me know what you think, or let's catch up in a call to discuss. Thanks for your patience and cooperation so far.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants