fix(m9/T32b): resolve $ref in InboundAPI runtime validators (no deploy-passes/runtime-400); diamond test; ref-annotation message
This commit is contained in:
@@ -104,10 +104,10 @@ public sealed class InboundApiSchema
|
|||||||
public static InboundApiSchema? Parse(string? json, Func<string, string?>? resolveRef)
|
public static InboundApiSchema? Parse(string? json, Func<string, string?>? resolveRef)
|
||||||
{
|
{
|
||||||
var result = ParseWithRefs(json, resolveRef);
|
var result = ParseWithRefs(json, resolveRef);
|
||||||
if (result.UnresolvedRefs.Count > 0)
|
if (result.UnresolvedReferences.Count > 0)
|
||||||
{
|
{
|
||||||
throw new JsonException(
|
throw new JsonException(
|
||||||
$"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedRefs)}.");
|
$"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedReferences.Select(r => r.Describe()))}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Schema;
|
return result.Schema;
|
||||||
@@ -141,7 +141,7 @@ public sealed class InboundApiSchema
|
|||||||
return new SchemaParseResult(null, []);
|
return new SchemaParseResult(null, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
var unresolved = new List<string>();
|
var unresolved = new List<UnresolvedSchemaRef>();
|
||||||
// The active-ref set tracks the refs being resolved on the CURRENT path so a
|
// The active-ref set tracks the refs being resolved on the CURRENT path so a
|
||||||
// cycle (A→B→A) is detected and reported instead of recursing forever.
|
// cycle (A→B→A) is detected and reported instead of recursing forever.
|
||||||
var ctx = new RefResolutionContext(resolveRef, unresolved, new HashSet<string>(StringComparer.Ordinal));
|
var ctx = new RefResolutionContext(resolveRef, unresolved, new HashSet<string>(StringComparer.Ordinal));
|
||||||
@@ -166,7 +166,7 @@ public sealed class InboundApiSchema
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record RefResolutionContext(
|
private sealed record RefResolutionContext(
|
||||||
Func<string, string?>? Resolver,
|
Func<string, string?>? Resolver,
|
||||||
List<string> Unresolved,
|
List<UnresolvedSchemaRef> Unresolved,
|
||||||
HashSet<string> ActiveRefs);
|
HashSet<string> ActiveRefs);
|
||||||
|
|
||||||
private static InboundApiSchema ParseSchema(JsonElement el, int depth, RefResolutionContext ctx)
|
private static InboundApiSchema ParseSchema(JsonElement el, int depth, RefResolutionContext ctx)
|
||||||
@@ -242,6 +242,22 @@ public sealed class InboundApiSchema
|
|||||||
/// <summary>The placeholder type for an unresolvable <c>$ref</c> node (dangling, cyclic, or over-depth).</summary>
|
/// <summary>The placeholder type for an unresolvable <c>$ref</c> node (dangling, cyclic, or over-depth).</summary>
|
||||||
private const string UnresolvedRefType = "ref";
|
private const string UnresolvedRefType = "ref";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M9-T32b — cheap pre-flight check: does this definition JSON contain ANY
|
||||||
|
/// <c>$ref</c> token at all? Lets a caller (e.g. the InboundAPI runtime path) skip the
|
||||||
|
/// shared-schema library pre-load entirely when a schema uses no references — so a
|
||||||
|
/// <c>$ref</c>-free method pays NO extra cost beyond today. The check is intentionally
|
||||||
|
/// conservative (a substring scan, not a parse): it may return <c>true</c> for a
|
||||||
|
/// schema whose only <c>$ref</c> is not a <c>lib:</c> reference, in which case the
|
||||||
|
/// subsequent <see cref="ParseWithRefs"/> simply never consults the resolver — correct,
|
||||||
|
/// just not maximally lazy. It never returns <c>false</c> when a real <c>lib:</c> ref
|
||||||
|
/// is present, so it is always safe to gate the pre-load on.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="json">The definition JSON to scan; <c>null</c>/empty yields <c>false</c>.</param>
|
||||||
|
/// <returns><c>true</c> when the JSON contains a <c>$ref</c> token and a resolver may be needed.</returns>
|
||||||
|
public static bool MightContainRef(string? json) =>
|
||||||
|
!string.IsNullOrEmpty(json) && json.Contains("$ref", StringComparison.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recognizes a <c>{"$ref":"lib:Name"}</c> reference node and extracts its target
|
/// Recognizes a <c>{"$ref":"lib:Name"}</c> reference node and extracts its target
|
||||||
/// name (the part after the <c>lib:</c> scheme prefix). Returns <c>false</c> for any
|
/// name (the part after the <c>lib:</c> scheme prefix). Returns <c>false</c> for any
|
||||||
@@ -294,14 +310,14 @@ public sealed class InboundApiSchema
|
|||||||
// ParseWithRefs path rather than aborting the whole parse with a throw.
|
// ParseWithRefs path rather than aborting the whole parse with a throw.
|
||||||
if (depth >= MaxDepth)
|
if (depth >= MaxDepth)
|
||||||
{
|
{
|
||||||
ctx.Unresolved.Add($"{refName} (ref nesting exceeds depth {MaxDepth})");
|
ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, $"ref nesting exceeds depth {MaxDepth}"));
|
||||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycle guard: this name is already being resolved on the current path.
|
// Cycle guard: this name is already being resolved on the current path.
|
||||||
if (ctx.ActiveRefs.Contains(refName))
|
if (ctx.ActiveRefs.Contains(refName))
|
||||||
{
|
{
|
||||||
ctx.Unresolved.Add($"{refName} (cyclic reference)");
|
ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, "cyclic reference"));
|
||||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +325,7 @@ public sealed class InboundApiSchema
|
|||||||
if (string.IsNullOrWhiteSpace(referenced))
|
if (string.IsNullOrWhiteSpace(referenced))
|
||||||
{
|
{
|
||||||
// Dangling: the seam can't resolve it (or no seam was supplied).
|
// Dangling: the seam can't resolve it (or no seam was supplied).
|
||||||
ctx.Unresolved.Add(refName);
|
ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, Reason: null));
|
||||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,19 +607,64 @@ public sealed class InboundApiSchema
|
|||||||
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
|
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
|
||||||
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
|
public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single <c>{"$ref":"lib:Name"}</c> reference that could NOT be resolved during
|
||||||
|
/// <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b). The reference <see cref="Name"/>
|
||||||
|
/// is kept SEPARATE from the diagnostic <see cref="Reason"/> so a message can render the
|
||||||
|
/// pointer cleanly (e.g. <c>schema 'lib:Foo' could not be resolved (cyclic reference)</c>)
|
||||||
|
/// rather than embedding the annotation inside the <c>lib:</c>-looking string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">
|
||||||
|
/// The library entry name (the part after the <c>lib:</c> scheme prefix) that could not be
|
||||||
|
/// resolved — never carries an annotation.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Reason">
|
||||||
|
/// The diagnostic reason for cyclic/over-depth cases (e.g. <c>"cyclic reference"</c> or
|
||||||
|
/// <c>"ref nesting exceeds depth 64"</c>), or <c>null</c> for a plain dangling reference
|
||||||
|
/// (the seam returned <c>null</c>, or no seam was supplied).
|
||||||
|
/// </param>
|
||||||
|
public sealed record UnresolvedSchemaRef(string Name, string? Reason)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Renders this reference as the legacy single-string form (<c>Name</c> for a plain
|
||||||
|
/// dangling ref, <c>"Name (Reason)"</c> when annotated) — used to project the
|
||||||
|
/// backward-compatible <see cref="SchemaParseResult.UnresolvedRefs"/> list.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The reference name, with the reason appended in parentheses when present.</returns>
|
||||||
|
public string ToLegacyString() => Reason is null ? Name : $"{Name} ({Reason})";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders this reference for an end-user error message, keeping the <c>lib:</c>-qualified
|
||||||
|
/// pointer name separate from the parenthesised reason
|
||||||
|
/// (e.g. <c>lib:Foo (cyclic reference)</c> or just <c>lib:Foo</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The <c>lib:</c>-qualified reference, with the reason appended in parentheses when present.</returns>
|
||||||
|
public string Describe() => Reason is null ? $"lib:{Name}" : $"lib:{Name} ({Reason})";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The outcome of <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b): the parsed
|
/// The outcome of <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b): the parsed
|
||||||
/// schema (with <c>{"$ref":"lib:Name"}</c> references resolved where possible) plus the
|
/// schema (with <c>{"$ref":"lib:Name"}</c> references resolved where possible) plus the
|
||||||
/// names of any references that could NOT be resolved — dangling (the seam returned
|
/// references that could NOT be resolved — dangling (the seam returned <c>null</c> or no
|
||||||
/// <c>null</c> or no seam was supplied), cyclic, or over-depth. A non-empty
|
/// seam was supplied), cyclic, or over-depth. A non-empty <see cref="UnresolvedReferences"/>
|
||||||
/// <see cref="UnresolvedRefs"/> is the deploy-blocking signal the validation layer acts on.
|
/// is the deploy-/runtime-blocking signal the validation layer acts on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Schema">The parsed schema, or <c>null</c> when the input was empty.</param>
|
/// <param name="Schema">The parsed schema, or <c>null</c> when the input was empty.</param>
|
||||||
/// <param name="UnresolvedRefs">
|
/// <param name="UnresolvedReferences">
|
||||||
/// The reference targets that could not be resolved, each annotated with the reason for
|
/// The structured reference targets that could not be resolved — each carrying the bare
|
||||||
/// cyclic/over-depth cases (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every
|
/// <see cref="UnresolvedSchemaRef.Name"/> separate from an optional
|
||||||
/// reference resolved.
|
/// <see cref="UnresolvedSchemaRef.Reason"/>. Empty when every reference resolved.
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record SchemaParseResult(
|
public sealed record SchemaParseResult(
|
||||||
InboundApiSchema? Schema,
|
InboundApiSchema? Schema,
|
||||||
IReadOnlyList<string> UnresolvedRefs);
|
IReadOnlyList<UnresolvedSchemaRef> UnresolvedReferences)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Backward-compatible flat view of <see cref="UnresolvedReferences"/>: each entry is the
|
||||||
|
/// reference name, with the reason appended in parentheses for cyclic/over-depth cases
|
||||||
|
/// (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every reference resolved. Prefer
|
||||||
|
/// <see cref="UnresolvedReferences"/> for new code that needs the name and reason apart.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> UnresolvedRefs =>
|
||||||
|
UnresolvedReferences.Select(r => r.ToLegacyString()).ToList();
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,7 +193,20 @@ public static class EndpointExtensions
|
|||||||
statusCode: 400);
|
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)
|
if (!paramResult.IsValid)
|
||||||
{
|
{
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.Scripting;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
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.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
@@ -307,11 +308,24 @@ public class InboundScriptExecutor
|
|||||||
? JsonSerializer.Serialize(result)
|
? JsonSerializer.Serialize(result)
|
||||||
: null;
|
: 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
|
// InboundAPI-014: validate the script's return value against the
|
||||||
// method's declared ReturnDefinition. A method whose script returns a
|
// method's declared ReturnDefinition. A method whose script returns a
|
||||||
// shape inconsistent with its definition must not silently emit a
|
// shape inconsistent with its definition must not silently emit a
|
||||||
// malformed 200 — surface it as a script failure (500) and log.
|
// 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)
|
if (!returnValidation.IsValid)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
|
|||||||
@@ -30,21 +30,45 @@ public static class ParameterValidator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="body">The parsed JSON request body; null or undefined if no body was supplied.</param>
|
/// <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="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>
|
/// <returns>A <see cref="ParameterValidationResult"/> with coerced parameter values on success, or an error message on failure.</returns>
|
||||||
public static ParameterValidationResult Validate(
|
public static ParameterValidationResult Validate(
|
||||||
JsonElement? body,
|
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
|
try
|
||||||
{
|
{
|
||||||
schema = InboundApiSchema.Parse(parameterDefinitions);
|
parsed = InboundApiSchema.ParseWithRefs(parameterDefinitions, resolveRef);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return ParameterValidationResult.Invalid("Invalid parameter definitions in method configuration");
|
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) —
|
// No parameters defined (or an object schema with no declared fields) —
|
||||||
// the body is unconstrained and yields an empty parameter set.
|
// the body is unconstrained and yields an empty parameter set.
|
||||||
if (schema is null || schema.Type != "object" || schema.Fields.Count == 0)
|
if (schema is null || schema.Type != "object" || schema.Fields.Count == 0)
|
||||||
@@ -94,6 +118,18 @@ public static class ParameterValidator
|
|||||||
return ParameterValidationResult.Valid(result);
|
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>
|
/// <summary>
|
||||||
/// Converts a validated JSON element to the CLR value handed to the script.
|
/// 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
|
/// Validation has already passed, so this only shapes the value: scalars to
|
||||||
|
|||||||
@@ -36,8 +36,21 @@ public static class ReturnValueValidator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="resultJson">The JSON-serialized script return value to validate.</param>
|
/// <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="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>
|
/// <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))
|
if (string.IsNullOrWhiteSpace(returnDefinition))
|
||||||
{
|
{
|
||||||
@@ -45,10 +58,14 @@ public static class ReturnValueValidator
|
|||||||
return ReturnValidationResult.Valid();
|
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
|
try
|
||||||
{
|
{
|
||||||
schema = InboundApiSchema.Parse(returnDefinition);
|
parsed = InboundApiSchema.ParseWithRefs(returnDefinition, resolveRef);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -56,6 +73,14 @@ public static class ReturnValueValidator
|
|||||||
"Invalid return definition in method configuration");
|
"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
|
// A schema that declares no constraints (e.g. an object schema with no
|
||||||
// fields) leaves the return value unconstrained.
|
// fields) leaves the return value unconstrained.
|
||||||
if (schema is null || (schema.Type == "object" && schema.Fields.Count == 0))
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,10 +186,15 @@ public class ValidationService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var missing in parsed.UnresolvedRefs)
|
foreach (var missing in parsed.UnresolvedReferences)
|
||||||
{
|
{
|
||||||
|
// Keep the lib:-qualified pointer name SEPARATE from the diagnostic
|
||||||
|
// reason so the message reads "schema 'lib:Foo' could not be resolved
|
||||||
|
// (cyclic reference)" rather than embedding the annotation inside the
|
||||||
|
// lib:-looking string (M9-T32b code-review MINOR).
|
||||||
|
var reasonSuffix = missing.Reason is null ? string.Empty : $" ({missing.Reason})";
|
||||||
errors.Add(ValidationEntry.Error(ValidationCategory.SchemaReference,
|
errors.Add(ValidationEntry.Error(ValidationCategory.SchemaReference,
|
||||||
$"Script '{scriptName}' {schemaLabel} references schema 'lib:{missing}' which could not be resolved.",
|
$"Script '{scriptName}' {schemaLabel} references schema 'lib:{missing.Name}' which could not be resolved{reasonSuffix}.",
|
||||||
scriptName));
|
scriptName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -219,4 +219,37 @@ public class InboundApiSchemaRefTests
|
|||||||
Assert.Null(result.Schema);
|
Assert.Null(result.Schema);
|
||||||
Assert.Empty(result.UnresolvedRefs);
|
Assert.Empty(result.UnresolvedRefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Diamond: two sibling refs to the SAME name resolve (not a cycle) ───────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseWithRefs_DiamondSiblingRefs_ResolveBothNotFalseCycle()
|
||||||
|
{
|
||||||
|
// Two sibling properties BOTH reference {"$ref":"lib:Shared"}. The shared
|
||||||
|
// name is popped from the cycle guard after each subtree is resolved, so the
|
||||||
|
// SECOND sibling must resolve too — a diamond is NOT a cycle and must not
|
||||||
|
// trip the guard. Regression for the finally-pop semantics in ResolveLibRef.
|
||||||
|
const string shared = """{"type":"object","properties":{"v":{"type":"integer"}},"required":["v"]}""";
|
||||||
|
const string json =
|
||||||
|
"""{"type":"object","properties":{"left":{"$ref":"lib:Shared"},"right":{"$ref":"lib:Shared"}}}""";
|
||||||
|
|
||||||
|
var result = InboundApiSchema.ParseWithRefs(
|
||||||
|
json,
|
||||||
|
name => name == "Shared" ? shared : null);
|
||||||
|
|
||||||
|
// Neither sibling should be reported unresolved — both resolved cleanly.
|
||||||
|
Assert.Empty(result.UnresolvedRefs);
|
||||||
|
Assert.NotNull(result.Schema);
|
||||||
|
Assert.Equal(2, result.Schema!.Fields.Count);
|
||||||
|
|
||||||
|
foreach (var sibling in new[] { "left", "right" })
|
||||||
|
{
|
||||||
|
var field = Assert.Single(result.Schema.Fields, f => f.Name == sibling);
|
||||||
|
Assert.Equal("object", field.Schema.Type);
|
||||||
|
var inner = Assert.Single(field.Schema.Fields);
|
||||||
|
Assert.Equal("v", inner.Name);
|
||||||
|
Assert.True(inner.Required);
|
||||||
|
Assert.Equal("integer", inner.Schema.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M9-T32b runtime regression: the InboundAPI RUNTIME validators
|
||||||
|
/// (<see cref="ParameterValidator"/> / <see cref="ReturnValueValidator"/>) must
|
||||||
|
/// resolve <c>{"$ref":"lib:Name"}</c> library references through the same seam the
|
||||||
|
/// deploy-time path uses. Before this fix the runtime validators called the
|
||||||
|
/// single-arg <c>InboundApiSchema.Parse(json)</c> with NO resolver, so a schema
|
||||||
|
/// containing a <c>$ref</c> threw <see cref="JsonException"/> and every runtime
|
||||||
|
/// invocation of the method returned a 400 ("Invalid ... definitions ...") — a
|
||||||
|
/// deploy-passes/runtime-breaks defect.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// These tests assert: a VALID ref resolves and validation proceeds against the
|
||||||
|
/// referenced shape; a DANGLING ref yields a CLEAR Invalid result naming the
|
||||||
|
/// missing reference (NOT an opaque "Invalid ... definitions" from a swallowed
|
||||||
|
/// <see cref="JsonException"/>); and schemas WITHOUT a <c>$ref</c> are unaffected
|
||||||
|
/// when no resolver is supplied.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class SchemaRefRuntimeValidationTests
|
||||||
|
{
|
||||||
|
private static JsonElement Body(string json)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ParameterValidator: $ref in parameter definitions ─────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParameterValidator_ValidLibRef_ResolvesAndValidates()
|
||||||
|
{
|
||||||
|
// The parameter schema is a single {"$ref":"lib:OrderRequest"} that the
|
||||||
|
// resolver maps to an object schema with one required integer field.
|
||||||
|
const string orderRequest =
|
||||||
|
"""{"type":"object","properties":{"qty":{"type":"integer"}},"required":["qty"]}""";
|
||||||
|
const string paramDef = """{"$ref":"lib:OrderRequest"}""";
|
||||||
|
|
||||||
|
var ok = ParameterValidator.Validate(
|
||||||
|
Body("""{"qty":5}"""),
|
||||||
|
paramDef,
|
||||||
|
resolveRef: name => name == "OrderRequest" ? orderRequest : null);
|
||||||
|
|
||||||
|
Assert.True(ok.IsValid, ok.ErrorMessage);
|
||||||
|
Assert.Equal(5L, ok.Parameters["qty"]);
|
||||||
|
|
||||||
|
// And a body that violates the resolved shape is rejected with a real
|
||||||
|
// type error (NOT a configuration/parse error) — proving the ref shape
|
||||||
|
// is actually being enforced.
|
||||||
|
var bad = ParameterValidator.Validate(
|
||||||
|
Body("""{"qty":"not-a-number"}"""),
|
||||||
|
paramDef,
|
||||||
|
resolveRef: name => name == "OrderRequest" ? orderRequest : null);
|
||||||
|
|
||||||
|
Assert.False(bad.IsValid);
|
||||||
|
Assert.DoesNotContain("definitions in method configuration", bad.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParameterValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef()
|
||||||
|
{
|
||||||
|
// The resolver cannot find "GoneLib" — at runtime this must surface as a
|
||||||
|
// CLEAR Invalid result that names the missing reference, NOT an opaque
|
||||||
|
// "Invalid parameter definitions" and NOT a thrown JsonException.
|
||||||
|
const string paramDef = """{"$ref":"lib:GoneLib"}""";
|
||||||
|
|
||||||
|
var result = ParameterValidator.Validate(
|
||||||
|
Body("""{"anything":1}"""),
|
||||||
|
paramDef,
|
||||||
|
resolveRef: _ => null);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.NotNull(result.ErrorMessage);
|
||||||
|
Assert.Contains("GoneLib", result.ErrorMessage!, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParameterValidator_NoRef_UnchangedWithoutResolver()
|
||||||
|
{
|
||||||
|
// A schema with no $ref behaves exactly as before when no resolver is
|
||||||
|
// supplied (the default-null resolver path).
|
||||||
|
const string def =
|
||||||
|
"""{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}""";
|
||||||
|
|
||||||
|
var ok = ParameterValidator.Validate(Body("""{"count":3}"""), def);
|
||||||
|
Assert.True(ok.IsValid, ok.ErrorMessage);
|
||||||
|
Assert.Equal(3L, ok.Parameters["count"]);
|
||||||
|
|
||||||
|
var bad = ParameterValidator.Validate(Body("""{"count":"x"}"""), def);
|
||||||
|
Assert.False(bad.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ReturnValueValidator: $ref in return definition ───────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReturnValueValidator_ValidLibRef_ResolvesAndValidates()
|
||||||
|
{
|
||||||
|
const string report =
|
||||||
|
"""{"type":"object","properties":{"total":{"type":"integer"}},"required":["total"]}""";
|
||||||
|
const string returnDef = """{"$ref":"lib:Report"}""";
|
||||||
|
|
||||||
|
var ok = ReturnValueValidator.Validate(
|
||||||
|
"""{"total":42}""",
|
||||||
|
returnDef,
|
||||||
|
resolveRef: name => name == "Report" ? report : null);
|
||||||
|
|
||||||
|
Assert.True(ok.IsValid, ok.ErrorMessage);
|
||||||
|
|
||||||
|
// A return value violating the resolved shape is rejected as a real
|
||||||
|
// validation failure, not a configuration error.
|
||||||
|
var bad = ReturnValueValidator.Validate(
|
||||||
|
"""{"total":"nope"}""",
|
||||||
|
returnDef,
|
||||||
|
resolveRef: name => name == "Report" ? report : null);
|
||||||
|
|
||||||
|
Assert.False(bad.IsValid);
|
||||||
|
Assert.DoesNotContain("definition in method configuration", bad.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReturnValueValidator_DanglingLibRef_ReturnsClearInvalidNamingTheRef()
|
||||||
|
{
|
||||||
|
const string returnDef = """{"$ref":"lib:GoneReturn"}""";
|
||||||
|
|
||||||
|
var result = ReturnValueValidator.Validate(
|
||||||
|
"""{"total":1}""",
|
||||||
|
returnDef,
|
||||||
|
resolveRef: _ => null);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains("GoneReturn", result.ErrorMessage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReturnValueValidator_NoRef_UnchangedWithoutResolver()
|
||||||
|
{
|
||||||
|
const string def =
|
||||||
|
"""{"type":"object","properties":{"total":{"type":"integer"}},"required":["total"]}""";
|
||||||
|
|
||||||
|
var ok = ReturnValueValidator.Validate("""{"total":7}""", def);
|
||||||
|
Assert.True(ok.IsValid, ok.ErrorMessage);
|
||||||
|
|
||||||
|
var bad = ReturnValueValidator.Validate("""{"total":"x"}""", def);
|
||||||
|
Assert.False(bad.IsValid);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user