Skip to content

Releases: NpgsqlRest/NpgsqlRest

NpgsqlRest v3.19.0

Choose a tag to compare

@github-actions github-actions released this 03 Jul 19:20

Version 3.19.0 (2026-07-03)

Full Changelog

This release introduces the SQL test runner (npgsqlrest --test) — write tests for your endpoints as plain .sql files, invoke endpoints in-process from inside a test, and assert on both the HTTP response and the database state, all within the test's own transaction. Also included: watch mode (--watch — re-run tests or restart the server on SQL and configuration changes), named parameters in SQL files (:name), a skip glob for the SQL file source, and the ability to mute an individual logger.


1. SQL test runner (--test)

npgsqlrest ./config.json --test

The runner discovers .sql test files, executes each on its own isolated connection, and reports per-assertion results to the console (and optionally JUnit XML for CI). A test file is ordinary SQL — arrange data, call an endpoint, assert on the result:

-- tests/get_users_excludes_caller.test.sql
begin;

insert into app.users (id, email, name) values (100, 'x@example.com', 'Fixture');

/*
GET /api/get-users
# @claim user_id=1
*/
select (select status from _response) = 200,
       'authenticated caller gets 200';
select (select body::jsonb @> '[{"email": "x@example.com"}]' from _response),
       'the fixture user is listed';

rollback;
NpgsqlRest test runner — 9 file(s)
PASS  tests/get_users_excludes_caller.test.sql  (2 assertions, 52ms)
...
19 passed, 0 failed, 0 error(s)  —  19 assertions in 9 files

How it works

In --test mode the client builds the full endpoint middleware exactly as in normal operation (endpoints from database routines and/or SQL files, authentication, custom parameters — everything), but instead of starting the web server it runs the test files and exits with a result code. Endpoint calls made from a test are in-process: no network, no running server — the complete endpoint pipeline (routing, authorization, parameter binding, execution, serialization) runs against a synthetic HTTP context.

The critical property is connection affinity: the in-process endpoint call runs on the test's own connection, inside the test's own transaction. A test can begin, insert fixture rows, call an endpoint that sees those uncommitted rows, assert on the response, and rollback — leaving no trace. Each test file gets its own non-pooled physical connection (fresh session: no temp-table, GUC, or prepared-statement carryover), and files run in parallel (MaxParallelism, default = processor count). If a file never rolls back, closing its physical connection aborts the open transaction — that is the safety net.

Test-mode invariants, applied automatically:

  • WrapInTransaction is forced off (the test file owns transaction control — the runner never injects BEGIN/COMMIT/ROLLBACK).
  • Response caching is disabled (a test never sees another test's cached response).
  • Code generation (HTTP files, TypeScript client, OpenAPI) is skipped — a test run never rewrites generated artifacts.

Test file anatomy

A test file is a sequence of SQL statements and HTTP blocks, executed strictly in order. Files are executed statement by statement (like psql): each statement runs in autocommit unless the file opens its own transaction. Semicolon splitting understands line/block comments, string literals with '' escapes, and dollar-quoted bodies — a do $$ … $$; block stays whole.

Assertions — a reported test is one of:

  • A boolean-returning SELECT. If the first column is boolean, the statement is an assertion: the first row's value must be true (false or null fails; zero rows passes vacuously; only the first row is examined). The optional second column is the assertion's name/message, shown in the report and used as the JUnit test-case name:
    select count(*) = 3, 'exactly three users are seeded' from app.users;
  • A do block. Passes unless it raises — assert inside a DO block raises SQLSTATE P0004, reported as a failure with the assert message. One DO block = one reported test (multiple asserts inside it are opaque to the runner):
    do $$ begin
        assert app.normalize_email(' X@Y.z ') = 'x@y.z', 'should trim and lowercase';
    end $$;

Any other statement is arrange/act — not counted as a test; it only surfaces if it errors. Any SQL error (other than an assert) is reported as an error with its SQLSTATE, message, statement text, and file:line.

Failing behavior is fail-fast per file: after the first failed/errored assertion the rest of the file does not run (a failed DO-block assert aborts the transaction anyway). Assertions that passed before the failure are still credited.

HTTP blocks — invoking endpoints

An HTTP request is embedded in a block comment whose first non-comment line is a request line — a single-request subset of the standard .http file syntax:

/*
POST /api/create-user
Content-Type: application/json
# @claim user_id=42
# @claim roles=admin
# @response created

{"name": "Grace Hopper", "email": "grace@example.com"}
*/

Syntax rules:

  • Request line: [HTTP] METHOD /path[?query] [HTTP/x] — the method is one of GET, POST, PUT, DELETE (the methods NpgsqlRest endpoints support); the path must start with / and must equal the endpoint's full path including UrlPathPrefix (default /api); the leading HTTP keyword and a trailing HTTP-version token are optional. A block comment whose first line is not a valid request line is an ordinary SQL comment — ignored.
  • Headers: Name: Value lines after the request line. Content-Type is picked up for the request body.
  • Directives (lines starting with # @ or // @, placed after the request line, before the body):
    • # @claim name=value — adds a claim to the acting principal. Repeatable, including the same claim type twice (e.g. two roles claims). Any # @claim makes the request authenticated; no # @claim means anonymous (an @authorize endpoint returns 401). Role checks and claim-to-parameter mappings (@user_parameters, ParameterNameClaimsMapping) work exactly as in production — tests exercise the real authorization path.
    • # @response name — capture this block's response into a temp table with the given name instead of the default.
  • Body: everything after the first blank line, verbatim. (Limitation: a literal */ inside the body ends the SQL comment early.)
  • Plain # or // lines are comments; unknown @ directives are ignored.

An HTTP block is an act step, not an assertion — it captures the response and produces no test result of its own. The assertions are the SQL statements that follow it. One request per block; use multiple blocks for multiple calls.

Endpoint kinds that cannot work meaningfully in-process are rejected with a clear error: SSE, upload, login/logout (inject the principal with # @claim instead), and outbound proxy/HTTP-type endpoints (tests must not call external services). A request whose path matches no endpoint still runs (a test may assert the 404 deliberately) but logs a warning — the most common cause is a path typo or a missing /api prefix.

The response temp table

Each HTTP block's response is captured into its own temp table on the test's connection, created fresh (no IF NOT EXISTS — a duplicate name, e.g. a repeated # @response name, fails the test loudly). Default columns:

Column Type Content
status int HTTP status code
body text response body (cast to ::jsonb to assert on JSON)
content_type text response content type
headers jsonb response headers
is_success boolean true for 2xx

Naming: a file with one HTTP block uses ResponseTempTable.Name (default _response); a file with two or more uses ResponseTempTable.MultiNamePattern (default _response_{n}) where {n} is the block's 1-based position — _response_1, _response_2, … A block with # @response name uses that name instead (it still counts in the numbering of the others). Column names are configurable; setting one to null/empty omits that column.

select (select body::jsonb ->> 'email' from _response) = 'grace@example.com',
       'email is normalized to lowercase';

Debugging captured responses — temp tables vanish with the test's rollback and connection, so you cannot inspect them afterwards (and re-issuing the request from an .http file cannot reproduce a response that depended on the test's uncommitted fixtures). Set ResponseTempTable.DebugTable (e.g. "_responses_debug"; default null = off) and every captured response is also mirrored into a permanent table — written on a separate autocommit connection, immune to rollbacks, recreated at the start of every run so it always holds the last run. One table covers everything: each HTTP block adds one row, with test_file, block (that block's response-table name — _response, a _response_{n} ordinal, or the # @response name), method, path, status, body, content_type, headers, is_success, and captured_at — so after a run you can open a query editor and dig into any response with jsonb operators. The temp-table semantics are unchanged; enabling it prints a loud warning (debugging aid — do not enable in CI). In the fresh-test-database workflow combine it with Keep: true, or teardown drops the database and the mirror with it.

Reusing scripts: \i and \ir includes

Shared SQL — fixture inserts, utility scripts — can be spliced into any test with psql's include syntax on its own line:

begin;

\ir fixtures/extra_users.sql    -- path relative to THIS file (psql \ir semantics)
\i  ./shared/reset_counters.sql -- path relative ...
Read more

NpgsqlRest v3.18.2

Choose a tag to compare

@github-actions github-actions released this 26 Jun 13:01

Version 3.18.2 (2026-06-26)

Full Changelog

Patch release with fixes for proxy endpoints that forward auto-filled parameters (v3.18.1) and the generated TypeScript client, plus a new opt-in option to omit server-filled parameters from generated request shapes. All surfaced by combining a @proxy with an HTTP Custom Type parameter.

What changed

1. Large auto-filled values no longer break the proxy query string

New option ProxyOptions.MaxForwardedQueryParamLength (default 2048). When a server-filled parameter is appended to the proxy upstream query string, a value longer than this limit is now skipped with a warning instead of being percent-encoded into the URL.

Previously, an HTTP Custom Type whose body field held a large payload (e.g. a scraped HTML page) was percent-encoded into the upstream query string, producing an oversized request line that the upstream rejected (HTTP 414 / 431) or that reset the connection. To forward such a value, use a body-carrying proxy method (POST/PUT/PATCH) so it travels in the request body instead. Set the option to 0 to disable the guard.

2. @body_parameter_name reliably matches HTTP Custom Type fields

@body_parameter_name now matches case-insensitively and accepts any of the parameter's names. For an HTTP Custom Type field expanded out of a composite parameter, all of these now resolve to the same field:

  • the converted (API) name — e.g. responseBody
  • the expanded signature name — e.g. _response_body (the name shown in the generated signature / .http file)
  • the base composite name — e.g. _response (shared by all expanded fields; resolves to the first one)

Previously the annotation value was force-lowercased and compared case-sensitively, so the camelCase converted name never matched, and the expanded signature name (_response_body) matched nothing at all — it is stored as neither the actual nor the converted name. This made it impossible to redirect a single expanded HTTP-type field (such as the response body) into the proxy request body.

Body-parameter resolution is now a single shared rule on the core endpoint (RoutineEndpoint.IsBodyParameter) used by request handling and every code generator, so they no longer drift. This also fixes the HTTP file and OpenAPI generators, which previously left a @body_parameter_name field (e.g. responseBody) in the query string / query parameters instead of moving it to the request body.

3. TypeScript client generation for @body_parameter_name

The generated TypeScript client was broken for an endpoint with @body_parameter_name:

  • the body expression was emitted as request.responseBody? — a syntax error (the TS optional ? suffix leaked into the runtime property name);
  • the query-string exclusion key was ["responseBody?"], so the body parameter was not stripped from the query string;
  • a body was emitted even for a GET request, which fetch forbids.

The generator now uses the parameter's bare name for the body expression and the exclusion key, only emits a fetch body for methods that can carry one (not GET), and — like the server — matches @body_parameter_name against the converted, actual, or expanded signature name of an HTTP Custom Type field (e.g. responseBody, _response, or _response_body).

4. Opt-in: omit automatic (server-filled) parameters from generated request shapes

New option OmitAutomaticParameters on all three generators — TsClientOptions, HttpFileOptions, and OpenApiOptions (default false, so generated output is unchanged unless you opt in).

When enabled, a parameter is omitted from the generated request (TypeScript request interface, .http query/body, OpenAPI query parameters / request body) when it is automatic (filled server-side, so a client value would be ignored) and optional. Automatic covers: HTTP Custom Type fields, resolved-parameter expressions, upload-metadata parameters, and — on endpoints that use user parameters — IP-address and user-claim parameters. The shared rule lives on the core endpoint (RoutineEndpoint.OmitParameterFromGeneratedRequest), so the three generators stay consistent. When every parameter is omitted, the generated request collapses cleanly (no-argument TS function, bare .http URL, no OpenAPI parameters/requestBody).

This is the proper fix for the misleading case where, e.g., an HTTP Custom Type's responseBody field appeared as a settable request parameter even though the server always overrides it.

Why these go together

The pattern "fetch with an HTTP Custom Type, then @proxy to an upstream" now works cleanly end to end — server and generated client: redirect the (large) body field into the upstream request body with @body_parameter_name, while the remaining small fields travel on the query string under the new length guard.

Notes

  • ProxyOptions.MaxForwardedQueryParamLength is wired through the client config (appsettings.json, JSON schema, and the --config template).
  • No parameter ActualName semantics changed: expanded HTTP-type fields still share the composite base name so they reassemble into the single SQL argument; the per-field name is matched via an internal alias only.

Tests

NpgsqlRestTests/ProxyTests/ProxyHttpTypeProbeTest.cs adds three cases (WireMock proxy target echoing the received URL / body): body redirect by converted name (responseBody), body redirect by expanded signature name (_response_body), and an oversized HTTP-type body field skipped from the proxy query string. NpgsqlRestTests/TsClientTests/BodyParamGetTests.cs covers the generated client for @body_parameter_name endpoints: a GET case (no ?-suffixed name, parameter excluded from the query, no fetch body on GET) and a POST HTTP-Custom-Type case targeted by the expanded name _response_body (body emitted as request.responseBody, excluded from the query). The HTTP file and OpenAPI generators are covered for the same expanded-name body redirect (BodyParamToBodyTests). OmitAutomaticParameters is covered for each generator (TsClient: all-omitted no-arg function + mixed-params; HttpFiles and OpenAPI: query/body omission of HTTP Custom Type fields). Full suite green (2301).

NpgsqlRest v3.18.1

Choose a tag to compare

@github-actions github-actions released this 23 Jun 12:19

Version 3.18.1 (2026-06-23)

Full Changelog

Patch release that makes all automatic (server-filled) parameters forward to proxy endpoints consistently, in the endpoint's native parameter shape.

What changed

When an endpoint is a proxy, the parameters that NpgsqlRest fills server-side are now forwarded to the upstream uniformly, regardless of source:

  • user claims (claim-mapped parameters),
  • IP address parameter,
  • HTTP Custom Type fields (the auto-filled responseBody / responseStatusCode / … on a routine with an HTTP Custom Type parameter),
  • resolved-parameter expressions (values looked up server-side via SQL).

All of them follow the same placement rule, which mirrors how the endpoint itself receives parameters — not the HTTP verb:

  • The parameter designated as the body parameter (@body_parameter_name) carries the raw request body.
  • Otherwise placement follows the endpoint's RequestParamType: QueryString → appended to the proxy query string; BodyJson → merged into the proxy JSON body (typed: numbers, booleans, embedded JSON, or strings), when the proxy method can carry a JSON body.

This is additive: the verbatim incoming request is still forwarded; the automatic parameters are added on top, so the upstream receives the same parameter set the routine would have.

Why

Previously the behavior was inconsistent: user-claim and IP parameters were always appended to the query string, HTTP Custom Type fields and resolved parameters were not forwarded at all, and a passthrough proxy discarded the auto-filled values entirely (the outbound HTTP Custom Type call fired but its result went nowhere). Now every automatic parameter behaves the same way.

Behavior change to note

User-claim and IP parameters now follow RequestParamType like every other automatic parameter. For a QueryString endpoint (the default for GET) they remain in the query string, exactly as before. For a BodyJson endpoint they are now merged into the JSON body rather than forced onto the query string. Method does not decide placement — RequestParamType does (a POST endpoint can use param_type query and its parameters then go to the query string).

Notes

  • Body merging applies only when the forwarded request carries a JSON content type; multipart and non-JSON bodies are forwarded verbatim.
  • Only the expanded per-field HTTP Custom Type parameters (DB-function shape) are forwarded; single-composite HTTP parameters (SQL-file shape) are not.

Tests

NpgsqlRestTests/ProxyTests/ProxyHttpTypeProbeTest.cs covers, via a WireMock proxy target that echoes the received URL / body: HTTP-type fields forwarded on the query (GET) and merged into the JSON body (POST, typed); placement following RequestParamType rather than the verb (a param_type query POST forwards to the query, not the body); and a resolved-parameter expression forwarded consistently. Existing user-claim / IP proxy tests continue to pass unchanged (GET → query string). Full suite green (2288).

NpgsqlRest v3.18.0

Choose a tag to compare

@github-actions github-actions released this 23 Jun 10:48

Version 3.18.0 (2026-06-23)

Full Changelog

The headline of this release is HTTP Custom Type response caching — outbound HTTP calls made by HTTP Custom Types can now be cached and reused, eliminating repeated calls to the same upstream within a configurable time window. The release also fixes a duplicate-outbound-call bug for HTTP types on database-function endpoints.

New Features

HTTP Custom Type response caching — @cache directive

An HTTP Custom Type can now opt into response caching with a @cache directive in its type comment, alongside the existing @timeout and @retry_delay directives. Directives appear before the request line:

comment on type books_api is '@cache 5m
GET https://books.toscrape.com/';

A cached type fires one outbound call for a given request shape; subsequent matching requests are served from the in-memory cache until the TTL elapses. For a type with no per-request placeholders (a constant URL/headers/body), that means a single shared upstream call per TTL window across the whole application — instead of one call per inbound request.

Behavior and safety rules:

  • Opt-in, GET-only. Caching is enabled per type by @cache. A @cache directive on any non-GET method is ignored with a startup warning — caching a mutating call is almost always a mistake.
  • TTL. @cache <interval> accepts the same formats as @timeout (5m, 30s, 1h, 00:05:00, or a bare number of seconds). A bare @cache (no interval) caches with no expiration (until the process restarts) and warns.
  • Success-only. Only successful (2xx) responses are cached, so a transient upstream failure is never pinned for the whole TTL — the next request re-fetches.
  • Stampede protection. A burst of concurrent requests for the same cache key coalesces into a single outbound call; the rest await the in-flight result (same Lazy<Task> coalescing model as the routine cache).
  • Cache key = HTTP method + resolved URL + resolved content-type + resolved headers + resolved body. Placeholders are resolved first, so per-request values vary the key naturally.

Configuration (HttpClientOptions):

  • CacheEnabled (default true) — global kill switch. When false, @cache directives are ignored and every request fires a fresh call.
  • MaxCacheEntries (default 10000) — bounds memory; once full, new responses are not cached (existing entries still serve and expire normally).
  • CachePruneIntervalSeconds (default 60) — how often expired entries are pruned.

Fixes

HTTP Custom Type request fired once per composite field on database-function endpoints

An endpoint backed by a database function/procedure whose parameter is an HTTP Custom Type fired one outbound HTTP call per field of the type on every inbound request (a 4-field type → 4 identical calls; a 6-field type → 6), multiplying latency and load on the target. SQL-file endpoints were not affected.

Cause. A composite function parameter is expanded into one parameter per field, each carrying the same TypeDescriptor.CustomType (the HTTP type name). The per-request list of HTTP types therefore held the same name N times, and the firing loop in HttpClientTypeHandler.InvokeAllAsync called InvokeAsync once per entry — while the fill loop immediately below resolves handlers by distinct type name. The design already assumes one call per distinct type; the firing loop just failed to match.

Fix. A guard in the firing loop requests each distinct HTTP type once, reusing the dictionary the fill loop already keys on. The established contract is preserved: one call per distinct HTTP type, shared from one response — two parameters referencing the same type still share a single call, and two different types remain two separate calls.

HTTP type directives after the headers were silently ignored

The @timeout, @retry_delay, and @cache directives are now parsed both before the request line and after the headers. Previously only the leading position (before the request line) was recognized, so a directive placed after the headers — as the documentation and examples showed — was silently dropped (e.g. a @timeout that never applied). Both placements are now equivalent. Real HTTP headers are unaffected: a header whose name merely starts with a directive keyword (e.g. Cache-Control) is still treated as a header.

Tests

  • Regression tests count actual outbound calls via WireMock response callbacks (the prior suite asserted content but never call counts): a 6-field type fires exactly one call (was 6), and two distinct types fire one call each.
  • Caching tests cover: cache hit reduces to one call, 6-field dedup + caching combined, error responses not cached, @cache ignored on POST, and TTL expiry. Parse-level tests cover the @cache directive forms and GET-only enforcement.

NpgsqlRest v3.17.0

Choose a tag to compare

@github-actions github-actions released this 13 Jun 10:42

Version 3.17.0 (2026-06-13)

Full Changelog

The headline of this release is MCP (Model Context Protocol) support — NpgsqlRest can project explicitly opted-in PostgreSQL routines as MCP tools that an AI agent can discover and call. Supporting that, the release adds neutral plugin extension points, makes one breaking change to the OpenAPI C# API, and ships two configuration/runtime fixes.

New Features

MCP (Model Context Protocol) server — new NpgsqlRest.Mcp plugin

NpgsqlRest can now expose opted-in PostgreSQL routines as MCP tools, so an AI agent can discover them (tools/list) and execute them (tools/call) over the Model Context Protocol (spec 2025-11-25). The entire MCP layer lives in the new plugin — core stays protocol-agnostic, built only on the neutral extension points below.

Opt-in, never automatic. A routine becomes a tool only when its PostgreSQL comment carries the mcp annotation:

  • mcp — expose as a tool; description derived from the comment prose.

  • mcp <text> — expose, with <text> as an inline (explicit) description.

  • mcp_description <text> (alias mcp_desc) — explicit, authoritative description.

  • mcp_name <name> — override the tool name (default: the routine name).

  • A bare mcp with no HTTP tag is MCP-only — the tool exists with no public HTTP route. The HTTP tag controls the REST route and mcp controls the tool, independently: HTTP GET + mcp = both interfaces; mcp alone = tool only (an endpoint that exists solely because a plugin requested it defaults to internal-only, so opting into MCP never silently widens the HTTP surface); internal remains the explicit way to hide a declared HTTP route. Works identically for SQL file endpoints (a .sql file with mcp and no HTTP tag becomes an MCP-only tool; files with neither annotation are skipped as non-endpoint scripts, as before). All other annotations (authorize, parameter handling, …) apply unchanged.

    Description precedence — the highest-priority source that is present wins, regardless of the order the lines appear in the comment; an explicit description suppresses the comment-prose fallback, so unrelated comment lines never leak in: mcp_description › inline mcp <text> › comment prose › routine name.

The endpoint. A single Streamable-HTTP JSON-RPC endpoint (default /mcp, POST only). It implements the lifecycle (initialize → protocol version + tools capability + serverInfo; notifications/initialized202; ping), tools/list (with a JSON-Schema inputSchema per tool, derived from the routine's parameters), and tools/call. Transport rules per spec: the Origin header is validated (DNS-rebinding protection — a present, untrusted origin → 403); a present MCP-Protocol-Version other than 2025-11-25400; GET405 (no SSE).

Calling a tool. tools/call runs the routine through the same pipeline as the HTTP endpoint, forwarding the authenticated principal so authorize checks apply. Arguments map to the routine as a query string (GET/DELETE), a JSON body (POST/PUT), or path-segment substitution. The result carries:

  • structuredContent (always a JSON object): a single value → { "value": … }; a record/composite (or a set collapsed with single) → the object itself; a set → { "items": [ … ] }. The text content block carries the same JSON, serialized (backward-compatibility).
  • outputSchema (declared on the tool) derived from the routine's return columns, nullable-aware so results always conform.
  • Two error channels: business failures → isError: true in the result; structural failures (unknown method/tool, malformed request) → JSON-RPC errors.

Authorization — OAuth 2.1 Resource Server (bring-your-own Authorization Server; token validation reuses the host's bearer authentication — NpgsqlRest is not an Authorization Server). Configured under McpOptions:Authorization:

  • Protected Resource Metadata (RFC 9728) served at /.well-known/oauth-protected-resource{UrlPath} when an Authorization Server is configured.
  • RequireAuthorization gates the endpoint → 401 with WWW-Authenticate: Bearer resource_metadata="…" (RFC 9728 §5.1) so the client can discover the AS; the PRM document itself stays anonymous.
  • Audience binding (RFC 8707): with a canonical Audience configured, a token must carry it (aud claim) or it is rejected with 401.
  • Per-tool authorization: the routine's authorize/role check runs on tools/call401 if called anonymously, 403 insufficient_scope if the role is missing (RFC 6750 §3.1; challenges include scope and resource_metadata). No authorization logic is duplicated in the plugin — it reuses core's check.

ConfigurationNpgsqlRest:McpOptions, disabled by default, surfaced in --config, --config-schema, and the JSON schema: Enabled, UrlPath (/mcp), ServerName (null → database name → "NpgsqlRest"), ServerVersion ("1.0.0"), Instructions, ToolDescriptionSuffix, RateLimiterPolicy, AllowedOrigins, and the Authorization object (RequireAuthorization, AuthorizationServers, ScopesSupported, Audience, ProtectedResourceMetadataPath, FilterToolsByRole).

Diagnostics & current limitations.

  • Enabling MCP does not enable authentication — it is configured separately (the host's Auth section). If RequireAuthorization is on but no authentication scheme is registered, a startup warning is logged.
  • A routine annotated mcp that also uses a feature with no MCP equivalent (login, logout, basic auth, upload, SSE) logs a build-time warning.
  • A routine's rate_limiter annotation does not carry to MCP (tools/call bypasses route middleware); pairing it with mcp logs a build-time warning. Use McpOptions:RateLimiterPolicy (a host-registered ASP.NET rate-limiter policy) to throttle the whole /mcp endpoint.
  • tools/list lists every opted-in tool by default (keeping them discoverable); set Authorization.FilterToolsByRole to hide tools the caller can't run. Authorization is enforced on tools/call regardless.
  • The JSON-RPC layer is hand-rolled over System.Text.Json.Nodes with relaxed escaping (conventional application/json output) — no reflection-based serialization, AOT-safe (verified via dotnet publish -p:PublishAot=true).

Plugin extension points on RoutineEndpoint

Neutral, plugin-facing hooks were added so a plugin can own its comment annotations without leaking plugin concepts into core (both MCP and the OpenAPI plugin are now built on these):

  • IEndpointCreateHandler.HandleCommentLine(...) (new default-interface method) — core offers each unrecognized comment line to handlers within its single parse pass; a handler claims it by returning a CommentLineResult (a log label + RequestsEndpoint). Tokens are pre-split by core. Non-breaking.
  • RoutineEndpoint.Items (lazy IDictionary<string, object?>) + TryGetItem — a per-endpoint property bag for plugin metadata (the HttpContext.Items pattern), namespaced by key.
  • RoutineEndpoint.UnhandledCommentLines (string[]?) — comment prose that neither core nor any handler claimed.
  • CommentsMode.OnlyAnnotated (new) — creates an endpoint when the comment has an HTTP tag or a plugin requests one. An endpoint created solely by a plugin request (no HTTP tag) defaults to internal-only — the plugin asked for a projection (an MCP tool), not a route — so a bare mcp is MCP-only (a debug log notes the defaulting). The client now defaults to OnlyAnnotated; existing OnlyWithHttpTag configs are unaffected — it is kept as an identical-behavior alias.
  • IEndpointCreateHandler.EndpointRequestingAnnotations (new default-interface property, default empty) — the annotation keywords for which the handler requests endpoints (Mcp: mcp, mcp_name, mcp_description, mcp_desc). Lets sources with a cheap textual pre-gate recognize endpoint candidates: the SQL file source passes a file whose comment carries an HTTP tag or one of these keywords, so a bare-mcp .sql file becomes an MCP-only tool while scripts with neither are still skipped without ever being described.

{name} annotation substitution can resolve allowlisted environment variables

The {name} placeholders in annotation values (response headers, custom parameters, HTTP custom type URL/headers/body) could only resolve request parameters. They can now also resolve allowlisted environment variables, so e.g. an outbound API key or a per-pod server name doesn't have to be routed through a request parameter:

comment on type weather_api is 'GET https://api.example.com/v1/current?city={_city}
Authorization: Bearer {WEATHER_API_KEY}';
  • Opt-in allowlist NpgsqlRest:AvailableEnvVars (mirrors StaticFiles:ParseContentOptions:AvailableEnvVars): array of names, or an object of name → default. Only listed names are ever read from the environment — the allowlist is the security boundary. (C# API: NpgsqlRestOptions.SubstitutionEnvironmentVariables, a resolved name → value dictionary.)
  • Resolved once at startup, matched case-insensitively, injected as the raw value. A routine parameter of the same name takes precedence.
  • Security: a value substituted into a response header is sent to the client — reserve secrets for outbound HTTP-type calls / custom parameters, and use response headers only for non-secret values (e.g. server/environment name).

TsClient: ExportTypes — emit request/response interfaces with the export keyword

The TypeScript client generator (NpgsqlRest.TsClient) previously emitted its request/response ...

Read more

NpgsqlRest v3.16.3

Choose a tag to compare

@github-actions github-actions released this 03 Jun 10:43

Version 3.16.3 (2026-06-03)

Full Changelog

Patch release that lets static-content parsing template environment-variable values into served files, alongside the existing per-user claim templating. This is aimed at Single-Page Apps deployed to Kubernetes: the SPA bundle is built once, and app-wide values (BUILD_LABEL, feature-flag toggles, analytics IDs) are injected from pod env vars at boot without rebuilding the bundle per environment.

What changed

AvailableEnvVars under StaticFiles:ParseContentOptions

A new optional config key lists environment variable names whose values are templated into static content using the same {NAME} tag syntax the claim path already uses:

"StaticFiles": {
  "Enabled": true,
  "ParseContentOptions": {
    "Enabled": true,
    "FilePaths": [ "/index.html" ],
    "AvailableClaims": [ "user_id", "user_name" ],
    "AvailableEnvVars": {
      "BUILD_LABEL": "local",
      "DEMO_FLAG": "false",
      "TRACKING_ID": ""
    }
  }
}
<script>
  window.__appConfig = {
    userId: {user_id},          // claim → 123 or null
    buildLabel: {BUILD_LABEL},  // env   → "demo" (or "local" default)
    demoMode: {DEMO_FLAG} === "true"
  };
</script>

Behaviour details:

  • Two forms. AvailableEnvVars accepts an array of names (["BUILD_LABEL"]; a missing variable resolves to the empty string) or an object of name→default pairs ({"DEMO_FLAG":"false"}; the default is used when the variable is absent). AvailableClaims gains the same object form, so an absent claim can resolve to a configured default instead of NULL.
  • Resolved once at startup. Env values are read at parser construction. A K8s pod restart re-reads them; changing a value in a running process is not picked up.
  • JSON-escaped, like claims. Each value is substituted as a complete, escaped JSON literal, so templates use a bare {NAME} token (no surrounding quotes) and an accidental quote/backslash in a value cannot break the JS string.
  • Claims win on collision. If a name exists both as a user claim and an env var, the per-request claim value takes precedence.

Security note

Anything listed in AvailableEnvVars is templated into static content served to any client — treat it as a public allowlist. Never list a secret (database password, API key, signing token). Resolution is an explicit per-name lookup; the whole environment is never exposed. This is distinct from the server-side Config:ParseEnvironmentVariables mechanism, which substitutes {ENV} tokens into appsettings.json values that never leave the server.

This is fully backward compatible: the new key is optional, and configs that omit it behave exactly as before.

NpgsqlRest v3.16.2

Choose a tag to compare

@github-actions github-actions released this 02 Jun 11:57

Version 3.16.2 (2026-06-02)

Full Changelog

Patch release that makes the rate-limiter rejection status code and message overridable per policy. Previously RateLimiterOptions:StatusCode and RateLimiterOptions:StatusMessage were the only values returned for a rejected request, applied globally regardless of which policy tripped. A config like a login_throttle policy with the message "Too many login attempts…" would return that same login-specific text for every rate-limited endpoint, even ones that have nothing to do with logins.

What changed

Per-policy StatusCode / StatusMessage overrides

Each named policy under RateLimiterOptions:Policies may now set its own StatusCode and/or StatusMessage:

"RateLimiterOptions": {
  "Enabled": true,
  "StatusCode": 429,                                  // global default
  "StatusMessage": "Too many requests. Please slow down.",
  "Policies": {
    "login_throttle": {
      "Type": "FixedWindow",
      "PermitLimit": 10,
      "WindowSeconds": 60,
      "StatusMessage": "Too many login attempts. Please wait a minute and try again.",
      "Partition": { "Sources": [ { "Type": "IpAddress" } ] }
    },
    "api": {
      "Type": "TokenBucket",
      "StatusCode": 503,
      "StatusMessage": "API capacity reached. Retry shortly."
    }
  }
}

A request rejected by a given policy now returns that policy's status code and message; a policy that omits either field inherits the global value. The override that applies is resolved at rejection time from the endpoint's rate-limiter policy name, so it is correct even though ASP.NET Core exposes only a single global OnRejected/RejectionStatusCode.

This is fully backward compatible: configs that set only the global StatusCode/StatusMessage behave exactly as before — those values simply become the defaults that policies may override.

New ready-to-use login_throttle default policy

The shipped appsettings.json now includes a disabled ("Enabled": false) login_throttle policy — 10 attempts per minute partitioned per client IP, with its own rejection message — so the common case is one flag away:

"login_throttle": {
  "Type": "FixedWindow",
  "Enabled": false,
  "PermitLimit": 10,
  "WindowSeconds": 60,
  "QueueLimit": 0,
  "AutoReplenishment": true,
  "StatusMessage": "Too many login attempts. Please wait a minute and try again.",
  "Partition": { "Sources": [ { "Type": "IpAddress" } ], "BypassAuthenticated": false }
}

Apply it to a login endpoint with the rate_limiter login_throttle comment annotation (or set it as DefaultPolicy).

Test coverage

NpgsqlRestTests/AuthTests/RateLimiterPerPolicyTests.cs (fixture RateLimiterPerPolicyTestFixture) boots the limiter through the same wiring BuildRateLimiter emits and drives the real Builder.ApplyRateLimiterRejectionAsync helper over HTTP, asserting:

  • a policy with a message-only override returns its own message but the global status code,
  • a policy overriding both returns its own status code (503) and message,
  • a policy with no override inherits the global status code and message.

Config-key validation for the new per-policy StatusCode/StatusMessage keys is covered in ConfigTests/ConfigValidationTests.cs.

NpgsqlRest v3.16.1

Choose a tag to compare

@github-actions github-actions released this 01 Jun 11:03

Version 3.16.1 (2026-06-01)

Full Changelog

Patch release that makes cache stampede protection actually fire for cached routine responses. The cache-options documentation has advertised stampede protection as a HybridCache feature, but the integration used IRoutineCache as a synchronous probe (Get / AddOrUpdate) that could not carry the SQL execution as the cache factory — so the protection never engaged. A burst of identical concurrent requests against a cold cache executed the underlying query N times, each taking a connection. In the worst case this exhausted Postgres' connection pool (remaining connection slots are reserved for roles with the SUPERUSER attribute), which combined with connection-retry backoff could pin the pool long enough to affect every app sharing the database.

What changed

IRoutineCache gains GetOrCreateAsync (additive)

A new method routes the cold-cache work through the cache so concurrent callers for the same key coalesce into a single execution:

ValueTask<object?> GetOrCreateAsync(
    RoutineEndpoint endpoint,
    string key,
    Func<CancellationToken, ValueTask<object?>> factory,
    TimeSpan? overrideExpiration = null,
    CancellationToken cancellationToken = default);

It ships as a default interface method (plain probe → factory → store, no coalescing), so any pre-existing custom IRoutineCache implementation compiles and behaves exactly as before — it simply gains no stampede protection until it overrides the method.

Note: although this is a new public API surface (conventionally a minor bump), it is shipped as a patch because it fixes an advertised-but-broken feature and is fully backward compatible via the default implementation.

Stampede protection per backend

  • Memory (RoutineCache) and Redis (RedisCache) — coalesce concurrent factory invocations through an in-flight ConcurrentDictionary<string, Lazy<Task>>. A burst collapses to one execution; the rest await the in-flight result.
  • HybridCache (HybridCacheWrapper) — delegates straight to HybridCache.GetOrCreateAsync, so Microsoft's built-in stampede protection now genuinely engages.

Middleware paths

  • Scalar single-value and passthrough proxy responses (value-shaped) route through GetOrCreateAsync. The connection is opened inside the factory, so coalesced waiters never touch the database. The passthrough proxy case additionally coalesces identical upstream HTTP calls.
  • Records / sets (the streaming path) use a per-key execution gate instead. This path streams rows to the client and disables caching mid-stream once a response exceeds MaxCacheableRows (default 1000), which does not fit the "compute one value, cache it, share it" factory model. The gate serializes concurrent requests for a key: the lead executes and (within the row limit) populates the cache, so the rest get a cache hit instead of re-executing. This caps concurrent DB executions per key at one in all cases — including over-limit responses, which serialize rather than run in parallel.

Effect

A burst of N identical requests against a cold cache now results in one database execution (within-limit) or a single serialized execution at a time (over-limit), instead of N concurrent executions. The worst-case fan-out from one event is bounded by the number of distinct cache keys (bounded by the schema), not by the number of clients.

Test coverage (read this honestly)

Automated coverage (NpgsqlRestTests/RoutineCacheTests/CacheStampedeTests.cs) runs against the in-memory backend with a live Postgres and asserts execution counts directly:

  • 50 concurrent cold scalar requests → exactly 1 execution; warm-cache burst → 0 further executions; 4 distinct keys → exactly 4 executions (one per key).
  • 50 concurrent cold set requests (within limit) → exactly 1 execution; over-limit set (1001 rows) → one execution per request, never cached, all responses correct.

The HybridCache path relies on Microsoft's own tested coalescing (Microsoft.Extensions.Caching.Hybrid) and the Redis path's coalescing is verified by inspection — neither is exercised by the test harness, which boots the core library with the default memory cache. Claims about those two backends are not backed by an automated test in this repo.

Known limitations

  • Cross-process coalescing is out of scope. Coalescing is in-process per NpgsqlRest instance; multiple instances each execute once. (HybridCache's Redis layer still shares the cached value across instances.)
  • Over-limit sets serialize, not coalesce. Responses above MaxCacheableRows (default 1000) are never cached, so the per-key gate makes concurrent requests for such an endpoint run one-at-a-time rather than sharing a result. This is deliberate: it caps both concurrent DB executions and peak memory (only one large set renders per key at a time) — but it does reduce throughput for a cached endpoint that returns more than MaxCacheableRows rows under load. Since such an endpoint is never actually cached, the right fix when this matters is to raise MaxCacheableRows so the result caches and coalesces, or to drop the cached annotation (restoring fully concurrent, uncached execution).
  • The records/sets gate is held during the response stream. Because the gate wraps streaming to the client (not just the DB read), a slow or stalled lead client can delay other clients requesting the same key until it finishes or its request cancels. Waiters honor their own cancellation token, so a waiter that gives up is never stuck. The scalar and proxy paths are unaffected — their coalescing slot covers only the upstream call, not the client write.
  • CommandCallbackAsync short-circuit under coalescing. If a user-supplied CommandCallbackAsync short-circuits the response on a cached scalar endpoint, coalesced waiters (not the lead) may observe an empty response. This affects only that specific hook on a cached endpoint.
  • Cancellation. The shared factory runs on the lead caller's token; if the lead cancels mid-flight, waiters retry (re-probe the cache, or one becomes the new lead). A waiter may rarely observe cancellation if the lead cancels at the exact moment of coalescing. This is a deliberate safety choice — the factory uses the lead's live connection, so fully detaching the shared work risks using a disposed connection.

NpgsqlRest v3.16.0

Choose a tag to compare

@github-actions github-actions released this 20 May 09:50

Version 3.16.0 (2026-05-20)

Full Changelog

Minor release fixing a long-standing class of bugs in the JSON-to-parameter parsers for the timestamp, timestamptz, time, and timetz PostgreSQL types: the parsers were silently shifting incoming values by the host process's UTC offset. Bumped to minor (not patch) because the corrected behavior changes how naive ISO strings (no Z, no offset) are interpreted on non-UTC hosts — see Breaking change below. The shift was invisible on UTC hosts (the default for mcr.microsoft.com/dotnet/aspnet and almost every Linux container) and only surfaced once the same image ran somewhere with TZ set to anything else — a Windows dev box, a Kubernetes pod with TZ overridden, or a non-UTC CI runner — at which point stored values diverged from the JSON the caller sent by the host's offset.

Fix: datetime parsers are now host-TZ-independent

TryParseTimestamp, TryParseTimestampTz, TryParseTime, and TryParseTimeTz in NpgsqlRest/ParameterParsers.cs all relied on the parameter-less DateTime.TryParse(value) overload. That overload's default DateTimeStyles.None converts offset-bearing strings to the host's local TZ and tags the result Kind=Local. The two *Tz parsers then called DateTime.SpecifyKind(v, DateTimeKind.Utc) on the local-shifted value — but SpecifyKind only relabels the kind, it does not convert. The result was a host-local wall-clock value labelled UTC, written to Postgres with a silent shift.

The timestamp and time parsers used the same buggy parse and sent the local-shifted value directly to Npgsql, which transmits the wall-clock verbatim for a without time zone column — the same silent shift, same size as the host's offset.

All four parsers now use:

DateTime.TryParse(
    value,
    CultureInfo.InvariantCulture,
    DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
    out var v)
  • AssumeUniversal treats naive ISO strings (no Z, no offset) as UTC — the canonical JSON-over-HTTP convention.
  • AdjustToUniversal converts any Z-bearing or offset-bearing value to UTC.

The result is a DateTime with Kind=Utc carrying the true UTC instant regardless of the host's TZ. The *Tz parsers use it directly. The without time zone parsers strip the kind back to Unspecified so Npgsql sends the UTC clock-time as the naive wall-clock value, matching the column semantics.

Why this was hidden so long

Almost every production container runs TZ=UTC by default. On a UTC host, DateTime.TryParse(...)'s local-conversion is a no-op and the SpecifyKind(Utc) "lie" coincidentally matches reality. The bug only manifests once the same code is deployed where TZ is anything else. The first symptom is usually a downstream report along the lines of "we send 2026-05-20T06:00:00Z and Postgres stored 08:00" — which is exactly the host's UTC offset.

The existing MultiParamsTests2 / MultiParamsQueryStringTests2 test pairs already hinted at this — both used Should().Match(t => t == "12:06:59..." || t == "11:06:59...") style assertions with a comment that read "integration server seems to have a different datetime alltogether". That was the bug, papered over. After this fix both tests assert single deterministic values.

TryParseDate left alone

DateOnly.TryParse rejects Z- and offset-bearing strings outright (verified across UTC, America/Los_Angeles, Europe/Zagreb, Pacific/Auckland) — it does not silently shift, so the date parser was not affected by the host-TZ bug class. It was however a separate papercut: callers sending full ISO timestamps (e.g. "2026-05-20T03:00:00Z") to a date column got a flat parse failure. TryParseDate now falls back to a DateTime parse and extracts the date portion when DateOnly.TryParse rejects the input, honoring the same JsonTimestampsAreUtc semantic as the other datetime parsers (UTC date when the flag is true, host-local date when false).

Breaking change

JSON timestamps are now interpreted as UTC:

  • Z-suffixed and offset-bearing ISO strings are converted to UTC.
  • Naive ISO strings (no offset, no Z) are assumed UTC rather than interpreted as host-local time.

Callers who relied on the previous "JSON is host-local" behavior — usually by accident, because the host happened to be UTC — will see no change. Callers who sent Z strings expecting UTC were silently broken on non-UTC hosts and are now correct.

Opt-out: NpgsqlRestOptions.JsonTimestampsAreUtc

Users whose downstream code genuinely depends on the legacy "naive timestamps are host-local" behavior — and who cannot update those callers to send Z-suffixed values — can restore the pre-3.16.0 behavior by setting JsonTimestampsAreUtc to false:

  • Library: new NpgsqlRestOptions { JsonTimestampsAreUtc = false, ... }.
  • Client (appsettings.json): "NpgsqlRest": { "JsonTimestampsAreUtc": false } (default is true).

When false, the four parsers fall back to the bare DateTime.TryParse(value) overload — Z/offset strings get host-local-converted and tagged Kind=Local, naive strings get parsed as Kind=Unspecified, and the *Tz parsers re-apply SpecifyKind(Utc) on top. That reproduces the exact pre-3.16.0 code path. Note that this is not recommended for new deployments: it puts you back in the bug class the rest of this release fixes. The flag exists purely as a compatibility escape hatch.

Tests

New file NpgsqlRestTests/HostTimeZoneIndependenceTests.cs covers all four parsers via echo functions and json_build_object round-trips:

  • timestamptz with Z suffix, with numeric offset, and naive (assumed UTC)
  • timestamp with Z suffix (stored as naive UTC clock-time)
  • timetz with Z suffix (round-trips as UTC)
  • time with Z suffix (UTC clock-time extracted)

Each assertion is exact — no host-TZ-tolerant ORs. The fixture forces the database to UTC at creation (alter database … set timezone to 'UTC'), so the assertions stay deterministic across runners. To verify host-TZ independence at the parser layer, run the suite under a non-UTC TZ env var (TZ=America/Los_Angeles dotnet test, for example) — the tests must still pass.

The two existing MultiParams* tests had their loose Should().Match(...) assertions for timestamptz and timetz replaced with single-value Should().Be(...) assertions, now that the parsers produce deterministic output.

Files touched

  • NpgsqlRest/ParameterParsers.cs — four parsers switched to AssumeUniversal | AdjustToUniversal.
  • NpgsqlRestTests/HostTimeZoneIndependenceTests.cs — new, six tests covering the four type variants.
  • NpgsqlRestTests/ParamTests/MultiParamsTests2.cs — tightened timestamptz / timetz assertions.
  • NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs — same.

No config changes, no API surface changes.

NpgsqlRest v3.15.2

Choose a tag to compare

@github-actions github-actions released this 13 May 08:21

Version 3.15.2 (2026-05-11)

Full Changelog

Patch release finishing the config-validator fix started in 3.15.1. That release made Auth:Schemes validate by Type rather than by name; this one applies the same treatment to the two sister sections (RateLimiterOptions:Policies, CacheOptions:Profiles) that share the same "name-keyed open dictionary" shape, plus a small consistency win for ValidationOptions:Rules. Library version unchanged — fixes live entirely in NpgsqlRestClient/ConfigDefaults.cs.

Fix: RateLimiterOptions:Policies validates by Type, not by name

A configuration like

{
  "Config": { "ValidateConfigKeys": "Error" },
  "RateLimiterOptions": {
    "Enabled": true,
    "Policies": {
      "login_throttle": {
        "Type": "FixedWindow",
        "PermitLimit": 10,
        "WindowSeconds": 60,
        "Partition": { "Sources": [{ "Type": "IpAddress" }] }
      }
    }
  }
}

failed startup with Unknown configuration key: RateLimiterOptions:Policies:login_throttle. The rate limiter itself registered login_throttle and rejected over-limit requests correctly — only the validator was wrong.

Root cause

FindUnknownConfigKeys walked the user's policy name (login_throttle) against the defaults schema, which contains illustrative example names (fixed, sliding, bucket, concurrency, per_user). Any other name was flagged unknown. With ValidateConfigKeys: "Error", that killed startup.

The 3.13.0 migration explicitly grouped RateLimiterOptions:Policies, CacheOptions:Profiles, and ValidationOptions:Rules as the same "object keyed by user-chosen name" shape, but only ValidationOptions:Rules was added to the validator's open-dictionary whitelist. The other two were missed.

What changed

FindUnknownConfigKeys now intercepts the descent at RateLimiterOptions:Policies:<name> and picks a per-Type schema:

  • FixedWindow: Type, Enabled, PermitLimit, WindowSeconds, QueueLimit, AutoReplenishment, Partition
  • SlidingWindow: Type, Enabled, PermitLimit, WindowSeconds, SegmentsPerWindow, QueueLimit, AutoReplenishment, Partition
  • TokenBucket: Type, Enabled, TokenLimit, TokensPerPeriod, ReplenishmentPeriodSeconds, QueueLimit, AutoReplenishment, Partition
  • Concurrency: Type, Enabled, PermitLimit, QueueLimit, OldestFirst, Partition

The shared Partition sub-schema (Sources: [{ Type, Name, Value }], BypassAuthenticated) is appended to every type. When Type is missing/invalid the validator skips that policy silently, matching the runtime behavior in BuildRateLimiter.

Behavior after the fix

  • Any custom policy name validates by its declared Type; example names continue to validate as before.
  • Typos inside a policy (e.g. PermitLimt) are caught — they were silently ignored when Policies was treated as an opaque dictionary.
  • Cross-type keys are caught: e.g. TokensPerPeriod placed on a FixedWindow policy is flagged, since it belongs to TokenBucket.

Fix: CacheOptions:Profiles validates by shape

Same root cause, same shape of fix. Custom profile names (session_cache, api_responses, etc.) failed validation when ValidateConfigKeys: "Error" was set, because the defaults contain example names (fast_memory, shared_redis, date_range_hybrid).

All cache profiles share the same key set regardless of backend type (Memory / Redis / Hybrid) — only the backend selection varies — so a single flat schema covers every profile:

Enabled, Type, Expiration, Parameters, When

Each When rule validates as { Parameter, Value, Then }. Typos inside a profile (e.g. Expirashun) are now caught.

Improvement: ValidationOptions:Rules now validates rule bodies

ValidationOptions:Rules was previously on the open-dictionary whitelist, so custom rule names (phone_number, etc.) passed validation — but typos inside a rule (e.g. Patrn instead of Pattern) also passed silently. All validation rules share the same flat key set regardless of Type (NotNull / NotEmpty / Required / Regex / MinLength / MaxLength):

Type, Pattern, MinLength, MaxLength, Message, StatusCode

ValidationOptions:Rules has been removed from the whitelist and is now validated against this flat schema. Custom rule names still pass; typos inside any rule now surface.

Tests

NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs gained 15 new tests covering all three sections: custom name acceptance, example-name regression coverage, typo flagging, cross-type key detection (rate limiter), Partition sub-block validation, When-rule sub-block validation, and missing-Type skip behavior for rate-limiter policies.

Files touched

  • NpgsqlRestClient/ConfigDefaults.cs — three new intercepts in FindUnknownConfigKeys, three new schema helpers, ValidationOptions:Rules removed from IsOpenDictionarySection.
  • NpgsqlRestTests/ConfigTests/ConfigValidationTests.cs — 15 new tests.

No changes to runtime config reading, no breaking changes, no library version bump (the bug was in NpgsqlRestClient only).