fix(m9/T32b): resolve $ref in InboundAPI runtime validators (no deploy-passes/runtime-400); diamond test; ref-annotation message
This commit is contained in:
@@ -193,7 +193,20 @@ public static class EndpointExtensions
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions);
|
||||
// M9-T32b: thread the JSON-Schema $ref resolution seam into parameter
|
||||
// validation so a method whose ParameterDefinitions use a {"$ref":"lib:Name"}
|
||||
// resolves the reference at RUNTIME (not just at deploy time). The shared-schema
|
||||
// library is pre-loaded ONCE per request into an in-memory map (the seam the
|
||||
// validator consumes is synchronous), and ONLY when the definition actually uses
|
||||
// a $ref — a $ref-free method skips the repository round-trip entirely. A dangling
|
||||
// ref surfaces as a clear, descriptive 400 naming the missing reference rather
|
||||
// than an opaque parse-error 400 (the deploy-passes/runtime-breaks defect).
|
||||
var sharedSchemaRepo =
|
||||
httpContext.RequestServices.GetService<ISharedSchemaRepository>();
|
||||
var resolveRef = await SchemaRefResolver.BuildAsync(
|
||||
sharedSchemaRepo, [method.ParameterDefinitions], httpContext.RequestAborted);
|
||||
|
||||
var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions, resolveRef);
|
||||
if (!paramResult.IsValid)
|
||||
{
|
||||
return Results.Json(
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
@@ -307,11 +308,24 @@ public class InboundScriptExecutor
|
||||
? JsonSerializer.Serialize(result)
|
||||
: null;
|
||||
|
||||
// M9-T32b: thread the JSON-Schema $ref resolution seam into return
|
||||
// validation so a method whose ReturnDefinition uses a {"$ref":"lib:Name"}
|
||||
// resolves the reference at RUNTIME (not just at deploy time). The
|
||||
// shared-schema library is pre-loaded ONCE from the per-execution DI scope
|
||||
// (the seam the validator consumes is synchronous), and ONLY when the
|
||||
// definition actually uses a $ref — a $ref-free return definition skips the
|
||||
// repository round-trip entirely. A dangling ref surfaces as a descriptive
|
||||
// validation failure naming the missing reference, not an opaque parse error.
|
||||
var sharedSchemaRepo =
|
||||
(scope?.ServiceProvider ?? _serviceProvider).GetService<ISharedSchemaRepository>();
|
||||
var resolveRef = await SchemaRefResolver.BuildAsync(
|
||||
sharedSchemaRepo, [method.ReturnDefinition], cts.Token);
|
||||
|
||||
// InboundAPI-014: validate the script's return value against the
|
||||
// method's declared ReturnDefinition. A method whose script returns a
|
||||
// shape inconsistent with its definition must not silently emit a
|
||||
// malformed 200 — surface it as a script failure (500) and log.
|
||||
var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition);
|
||||
var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition, resolveRef);
|
||||
if (!returnValidation.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
|
||||
@@ -30,21 +30,45 @@ public static class ParameterValidator
|
||||
/// </summary>
|
||||
/// <param name="body">The parsed JSON request body; null or undefined if no body was supplied.</param>
|
||||
/// <param name="parameterDefinitions">JSON Schema describing the method's parameters (an object schema), or null/empty when no parameters are defined. The legacy flat-array form is also accepted.</param>
|
||||
/// <param name="resolveRef">
|
||||
/// M9-T32b: optional JSON-Schema <c>$ref</c> resolution seam mapping a
|
||||
/// <c>{"$ref":"lib:Name"}</c> reference target's name to the referenced schema JSON
|
||||
/// (or <c>null</c> when the library entry does not exist). The endpoint pre-loads the
|
||||
/// shared-schema library (backed by <c>ISharedSchemaRepository</c>) and supplies it
|
||||
/// ONLY when the definition actually uses a <c>$ref</c>. <c>null</c> means no resolver:
|
||||
/// schemas with no <c>$ref</c> behave exactly as before; a <c>$ref</c> with no resolver
|
||||
/// (or a dangling one) is surfaced as a CLEAR invalid result naming the missing
|
||||
/// reference — NOT an opaque parse-error 400 from a thrown <see cref="JsonException"/>.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="ParameterValidationResult"/> with coerced parameter values on success, or an error message on failure.</returns>
|
||||
public static ParameterValidationResult Validate(
|
||||
JsonElement? body,
|
||||
string? parameterDefinitions)
|
||||
string? parameterDefinitions,
|
||||
Func<string, string?>? resolveRef = null)
|
||||
{
|
||||
InboundApiSchema? schema;
|
||||
// M9-T32b: parse through the ref-COLLECTING path. A {"$ref":"lib:Name"} that the
|
||||
// resolver can satisfy is resolved inline; a dangling/cyclic/over-depth ref is
|
||||
// collected (not thrown) so the runtime returns a descriptive "could not be
|
||||
// resolved" message instead of an opaque "Invalid parameter definitions" — the
|
||||
// deploy-passes/runtime-400 defect this fix closes.
|
||||
SchemaParseResult parsed;
|
||||
try
|
||||
{
|
||||
schema = InboundApiSchema.Parse(parameterDefinitions);
|
||||
parsed = InboundApiSchema.ParseWithRefs(parameterDefinitions, resolveRef);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ParameterValidationResult.Invalid("Invalid parameter definitions in method configuration");
|
||||
}
|
||||
|
||||
if (parsed.UnresolvedReferences.Count > 0)
|
||||
{
|
||||
return ParameterValidationResult.Invalid(
|
||||
$"Parameter definitions reference {DescribeUnresolved(parsed.UnresolvedReferences)} which could not be resolved");
|
||||
}
|
||||
|
||||
InboundApiSchema? schema = parsed.Schema;
|
||||
|
||||
// No parameters defined (or an object schema with no declared fields) —
|
||||
// the body is unconstrained and yields an empty parameter set.
|
||||
if (schema is null || schema.Type != "object" || schema.Fields.Count == 0)
|
||||
@@ -94,6 +118,18 @@ public static class ParameterValidator
|
||||
return ParameterValidationResult.Valid(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b: renders the unresolved <c>{"$ref":"lib:Name"}</c> references for a clear,
|
||||
/// descriptive runtime error — the bare <c>lib:</c>-qualified pointer name stays
|
||||
/// separate from any parenthesised reason (cyclic/over-depth), so the message reads
|
||||
/// e.g. <c>schema(s) 'lib:Foo' (cyclic reference)</c> rather than embedding the
|
||||
/// annotation inside the <c>lib:</c>-looking string.
|
||||
/// </summary>
|
||||
/// <param name="unresolved">The references that could not be resolved.</param>
|
||||
/// <returns>A human-readable description of the unresolved schema reference(s).</returns>
|
||||
internal static string DescribeUnresolved(IReadOnlyList<UnresolvedSchemaRef> unresolved) =>
|
||||
$"schema(s) {string.Join(", ", unresolved.Select(r => $"'{r.Describe()}'"))}";
|
||||
|
||||
/// <summary>
|
||||
/// Converts a validated JSON element to the CLR value handed to the script.
|
||||
/// Validation has already passed, so this only shapes the value: scalars to
|
||||
|
||||
@@ -36,8 +36,21 @@ public static class ReturnValueValidator
|
||||
/// </summary>
|
||||
/// <param name="resultJson">The JSON-serialized script return value to validate.</param>
|
||||
/// <param name="returnDefinition">JSON Schema describing the method's return value, or null/empty to skip validation. The legacy flat-array form is also accepted.</param>
|
||||
/// <param name="resolveRef">
|
||||
/// M9-T32b: optional JSON-Schema <c>$ref</c> resolution seam mapping a
|
||||
/// <c>{"$ref":"lib:Name"}</c> reference target's name to the referenced schema JSON
|
||||
/// (or <c>null</c> when the library entry does not exist). The executor pre-loads the
|
||||
/// shared-schema library (backed by <c>ISharedSchemaRepository</c>) and supplies it
|
||||
/// ONLY when the definition actually uses a <c>$ref</c>. <c>null</c> means no resolver:
|
||||
/// schemas with no <c>$ref</c> behave exactly as before; a <c>$ref</c> with no resolver
|
||||
/// (or a dangling one) is surfaced as a CLEAR invalid result naming the missing
|
||||
/// reference — NOT an opaque parse-error from a thrown <see cref="JsonException"/>.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="ReturnValidationResult"/> indicating success or describing the validation failures.</returns>
|
||||
public static ReturnValidationResult Validate(string? resultJson, string? returnDefinition)
|
||||
public static ReturnValidationResult Validate(
|
||||
string? resultJson,
|
||||
string? returnDefinition,
|
||||
Func<string, string?>? resolveRef = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(returnDefinition))
|
||||
{
|
||||
@@ -45,10 +58,14 @@ public static class ReturnValueValidator
|
||||
return ReturnValidationResult.Valid();
|
||||
}
|
||||
|
||||
InboundApiSchema? schema;
|
||||
// M9-T32b: parse through the ref-COLLECTING path so a {"$ref":"lib:Name"} the
|
||||
// resolver can satisfy is resolved inline, and a dangling/cyclic/over-depth ref is
|
||||
// surfaced as a descriptive "could not be resolved" message rather than an opaque
|
||||
// "Invalid return definition" from a swallowed JsonException.
|
||||
SchemaParseResult parsed;
|
||||
try
|
||||
{
|
||||
schema = InboundApiSchema.Parse(returnDefinition);
|
||||
parsed = InboundApiSchema.ParseWithRefs(returnDefinition, resolveRef);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -56,6 +73,14 @@ public static class ReturnValueValidator
|
||||
"Invalid return definition in method configuration");
|
||||
}
|
||||
|
||||
if (parsed.UnresolvedReferences.Count > 0)
|
||||
{
|
||||
return ReturnValidationResult.Invalid(
|
||||
$"Return definition references {ParameterValidator.DescribeUnresolved(parsed.UnresolvedReferences)} which could not be resolved");
|
||||
}
|
||||
|
||||
InboundApiSchema? schema = parsed.Schema;
|
||||
|
||||
// A schema that declares no constraints (e.g. an object schema with no
|
||||
// fields) leaves the return value unconstrained.
|
||||
if (schema is null || (schema.Type == "object" && schema.Fields.Count == 0))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b: builds the synchronous JSON-Schema <c>$ref</c> resolution seam the
|
||||
/// InboundAPI RUNTIME validators (<see cref="ParameterValidator"/> /
|
||||
/// <see cref="ReturnValueValidator"/>) consume, backed by the central shared-schema
|
||||
/// library (<see cref="ISharedSchemaRepository"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// Before this fix the runtime validators called the single-arg
|
||||
/// <c>InboundApiSchema.Parse(json)</c> with NO resolver, so a method whose
|
||||
/// parameter/return definition used a <c>{"$ref":"lib:Name"}</c> deployed fine
|
||||
/// (deploy-time validation resolves it) but FAILED at every runtime invocation with
|
||||
/// an opaque HTTP 400 — a deploy-passes/runtime-breaks defect. This helper mirrors how
|
||||
/// the deploy path (<c>FlatteningPipeline</c>) pre-loads the library ONCE into an
|
||||
/// in-memory name→JSON map and exposes a pure synchronous lookup as the seam, avoiding
|
||||
/// sync-over-async inside the validators.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The pre-load is gated on <see cref="InboundApiSchema.MightContainRef"/>: a definition
|
||||
/// that uses no <c>$ref</c> at all skips the repository round-trip entirely and returns
|
||||
/// <c>null</c> (no resolver), so a <c>$ref</c>-free method pays NO extra cost beyond
|
||||
/// today's behavior.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class SchemaRefResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a <c>lib:Name</c> → schema-JSON resolution seam for the supplied
|
||||
/// definitions, pre-loading the shared-schema library only when at least one of the
|
||||
/// definitions actually contains a <c>$ref</c>.
|
||||
/// </summary>
|
||||
/// <param name="repository">
|
||||
/// The shared-schema repository, or <c>null</c> when none is registered (e.g. a bare
|
||||
/// test-double provider) — then no resolver is produced and any <c>$ref</c> dangles.
|
||||
/// </param>
|
||||
/// <param name="definitions">The schema definition JSON strings to be validated (parameter and/or return).</param>
|
||||
/// <param name="ct">Cancellation token for the library load.</param>
|
||||
/// <returns>
|
||||
/// A synchronous <c>name → schema JSON?</c> resolver, or <c>null</c> when no definition
|
||||
/// uses a <c>$ref</c> (so the validators take their unchanged no-resolver path).
|
||||
/// </returns>
|
||||
public static async Task<Func<string, string?>?> BuildAsync(
|
||||
ISharedSchemaRepository? repository,
|
||||
IEnumerable<string?> definitions,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Skip the library round-trip entirely unless some definition uses a $ref.
|
||||
if (repository is null || !definitions.Any(InboundApiSchema.MightContainRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var library = await repository.ListAsync(ct);
|
||||
var map = library.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
||||
return name => map.GetValueOrDefault(name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user