diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs index f9e40995..aea25c87 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/InboundApiSchema.cs @@ -104,10 +104,10 @@ public sealed class InboundApiSchema public static InboundApiSchema? Parse(string? json, Func? resolveRef) { var result = ParseWithRefs(json, resolveRef); - if (result.UnresolvedRefs.Count > 0) + if (result.UnresolvedReferences.Count > 0) { 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; @@ -141,7 +141,7 @@ public sealed class InboundApiSchema return new SchemaParseResult(null, []); } - var unresolved = new List(); + var unresolved = new List(); // 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. var ctx = new RefResolutionContext(resolveRef, unresolved, new HashSet(StringComparer.Ordinal)); @@ -166,7 +166,7 @@ public sealed class InboundApiSchema /// private sealed record RefResolutionContext( Func? Resolver, - List Unresolved, + List Unresolved, HashSet ActiveRefs); private static InboundApiSchema ParseSchema(JsonElement el, int depth, RefResolutionContext ctx) @@ -242,6 +242,22 @@ public sealed class InboundApiSchema /// The placeholder type for an unresolvable $ref node (dangling, cyclic, or over-depth). private const string UnresolvedRefType = "ref"; + /// + /// M9-T32b — cheap pre-flight check: does this definition JSON contain ANY + /// $ref 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 + /// $ref-free method pays NO extra cost beyond today. The check is intentionally + /// conservative (a substring scan, not a parse): it may return true for a + /// schema whose only $ref is not a lib: reference, in which case the + /// subsequent simply never consults the resolver — correct, + /// just not maximally lazy. It never returns false when a real lib: ref + /// is present, so it is always safe to gate the pre-load on. + /// + /// The definition JSON to scan; null/empty yields false. + /// true when the JSON contains a $ref token and a resolver may be needed. + public static bool MightContainRef(string? json) => + !string.IsNullOrEmpty(json) && json.Contains("$ref", StringComparison.Ordinal); + /// /// Recognizes a {"$ref":"lib:Name"} reference node and extracts its target /// name (the part after the lib: scheme prefix). Returns false for any @@ -294,14 +310,14 @@ public sealed class InboundApiSchema // ParseWithRefs path rather than aborting the whole parse with a throw. 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 }; } // Cycle guard: this name is already being resolved on the current path. if (ctx.ActiveRefs.Contains(refName)) { - ctx.Unresolved.Add($"{refName} (cyclic reference)"); + ctx.Unresolved.Add(new UnresolvedSchemaRef(refName, "cyclic reference")); return new InboundApiSchema { Type = UnresolvedRefType }; } @@ -309,7 +325,7 @@ public sealed class InboundApiSchema if (string.IsNullOrWhiteSpace(referenced)) { // 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 }; } @@ -591,19 +607,64 @@ public sealed class InboundApiSchema /// The recursive type schema the field's value must satisfy. public sealed record InboundApiSchemaField(string Name, bool Required, InboundApiSchema Schema); +/// +/// A single {"$ref":"lib:Name"} reference that could NOT be resolved during +/// (M9-T32b). The reference +/// is kept SEPARATE from the diagnostic so a message can render the +/// pointer cleanly (e.g. schema 'lib:Foo' could not be resolved (cyclic reference)) +/// rather than embedding the annotation inside the lib:-looking string. +/// +/// +/// The library entry name (the part after the lib: scheme prefix) that could not be +/// resolved — never carries an annotation. +/// +/// +/// The diagnostic reason for cyclic/over-depth cases (e.g. "cyclic reference" or +/// "ref nesting exceeds depth 64"), or null for a plain dangling reference +/// (the seam returned null, or no seam was supplied). +/// +public sealed record UnresolvedSchemaRef(string Name, string? Reason) +{ + /// + /// Renders this reference as the legacy single-string form (Name for a plain + /// dangling ref, "Name (Reason)" when annotated) — used to project the + /// backward-compatible list. + /// + /// The reference name, with the reason appended in parentheses when present. + public string ToLegacyString() => Reason is null ? Name : $"{Name} ({Reason})"; + + /// + /// Renders this reference for an end-user error message, keeping the lib:-qualified + /// pointer name separate from the parenthesised reason + /// (e.g. lib:Foo (cyclic reference) or just lib:Foo). + /// + /// The lib:-qualified reference, with the reason appended in parentheses when present. + public string Describe() => Reason is null ? $"lib:{Name}" : $"lib:{Name} ({Reason})"; +} + /// /// The outcome of (M9-T32b): the parsed /// schema (with {"$ref":"lib:Name"} references resolved where possible) plus the -/// names of any references that could NOT be resolved — dangling (the seam returned -/// null or no seam was supplied), cyclic, or over-depth. A non-empty -/// is the deploy-blocking signal the validation layer acts on. +/// references that could NOT be resolved — dangling (the seam returned null or no +/// seam was supplied), cyclic, or over-depth. A non-empty +/// is the deploy-/runtime-blocking signal the validation layer acts on. /// /// The parsed schema, or null when the input was empty. -/// -/// The reference targets that could not be resolved, each annotated with the reason for -/// cyclic/over-depth cases (e.g. "Foo (cyclic reference)"). Empty when every -/// reference resolved. +/// +/// The structured reference targets that could not be resolved — each carrying the bare +/// separate from an optional +/// . Empty when every reference resolved. /// public sealed record SchemaParseResult( InboundApiSchema? Schema, - IReadOnlyList UnresolvedRefs); + IReadOnlyList UnresolvedReferences) +{ + /// + /// Backward-compatible flat view of : each entry is the + /// reference name, with the reason appended in parentheses for cyclic/over-depth cases + /// (e.g. "Foo (cyclic reference)"). Empty when every reference resolved. Prefer + /// for new code that needs the name and reason apart. + /// + public IReadOnlyList UnresolvedRefs => + UnresolvedReferences.Select(r => r.ToLegacyString()).ToList(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs index 5c70ed53..6b1d41c4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs @@ -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(); + var resolveRef = await SchemaRefResolver.BuildAsync( + sharedSchemaRepo, [method.ParameterDefinitions], httpContext.RequestAborted); + + var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions, resolveRef); if (!paramResult.IsValid) { return Results.Json( diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs index 75f1b1bc..8e50228e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs @@ -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(); + 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( diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs index f36ff743..8e343438 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs @@ -30,21 +30,45 @@ public static class ParameterValidator /// /// The parsed JSON request body; null or undefined if no body was supplied. /// 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. + /// + /// M9-T32b: optional JSON-Schema $ref resolution seam mapping a + /// {"$ref":"lib:Name"} reference target's name to the referenced schema JSON + /// (or null when the library entry does not exist). The endpoint pre-loads the + /// shared-schema library (backed by ISharedSchemaRepository) and supplies it + /// ONLY when the definition actually uses a $ref. null means no resolver: + /// schemas with no $ref behave exactly as before; a $ref 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 . + /// /// A with coerced parameter values on success, or an error message on failure. public static ParameterValidationResult Validate( JsonElement? body, - string? parameterDefinitions) + string? parameterDefinitions, + Func? 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); } + /// + /// M9-T32b: renders the unresolved {"$ref":"lib:Name"} references for a clear, + /// descriptive runtime error — the bare lib:-qualified pointer name stays + /// separate from any parenthesised reason (cyclic/over-depth), so the message reads + /// e.g. schema(s) 'lib:Foo' (cyclic reference) rather than embedding the + /// annotation inside the lib:-looking string. + /// + /// The references that could not be resolved. + /// A human-readable description of the unresolved schema reference(s). + internal static string DescribeUnresolved(IReadOnlyList unresolved) => + $"schema(s) {string.Join(", ", unresolved.Select(r => $"'{r.Describe()}'"))}"; + /// /// 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs index 03995c96..9827e077 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ReturnValueValidator.cs @@ -36,8 +36,21 @@ public static class ReturnValueValidator /// /// The JSON-serialized script return value to validate. /// JSON Schema describing the method's return value, or null/empty to skip validation. The legacy flat-array form is also accepted. + /// + /// M9-T32b: optional JSON-Schema $ref resolution seam mapping a + /// {"$ref":"lib:Name"} reference target's name to the referenced schema JSON + /// (or null when the library entry does not exist). The executor pre-loads the + /// shared-schema library (backed by ISharedSchemaRepository) and supplies it + /// ONLY when the definition actually uses a $ref. null means no resolver: + /// schemas with no $ref behave exactly as before; a $ref 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 . + /// /// A indicating success or describing the validation failures. - public static ReturnValidationResult Validate(string? resultJson, string? returnDefinition) + public static ReturnValidationResult Validate( + string? resultJson, + string? returnDefinition, + Func? 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)) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/SchemaRefResolver.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/SchemaRefResolver.cs new file mode 100644 index 00000000..4207effa --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/SchemaRefResolver.cs @@ -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; + +/// +/// M9-T32b: builds the synchronous JSON-Schema $ref resolution seam the +/// InboundAPI RUNTIME validators ( / +/// ) consume, backed by the central shared-schema +/// library (). +/// +/// +/// Before this fix the runtime validators called the single-arg +/// InboundApiSchema.Parse(json) with NO resolver, so a method whose +/// parameter/return definition used a {"$ref":"lib:Name"} 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 (FlatteningPipeline) 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. +/// +/// +/// +/// The pre-load is gated on : a definition +/// that uses no $ref at all skips the repository round-trip entirely and returns +/// null (no resolver), so a $ref-free method pays NO extra cost beyond +/// today's behavior. +/// +/// +internal static class SchemaRefResolver +{ + /// + /// Builds a lib:Name → 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 $ref. + /// + /// + /// The shared-schema repository, or null when none is registered (e.g. a bare + /// test-double provider) — then no resolver is produced and any $ref dangles. + /// + /// The schema definition JSON strings to be validated (parameter and/or return). + /// Cancellation token for the library load. + /// + /// A synchronous name → schema JSON? resolver, or null when no definition + /// uses a $ref (so the validators take their unchanged no-resolver path). + /// + public static async Task?> BuildAsync( + ISharedSchemaRepository? repository, + IEnumerable 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); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs index ea2bb00c..54e51e45 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs @@ -186,10 +186,15 @@ public class ValidationService 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, - $"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)); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs index fb766143..315a1781 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/InboundApi/InboundApiSchemaRefTests.cs @@ -219,4 +219,37 @@ public class InboundApiSchemaRefTests Assert.Null(result.Schema); 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); + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs new file mode 100644 index 00000000..ed98c33e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/SchemaRefRuntimeValidationTests.cs @@ -0,0 +1,150 @@ +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; + +namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; + +/// +/// M9-T32b runtime regression: the InboundAPI RUNTIME validators +/// ( / ) must +/// resolve {"$ref":"lib:Name"} library references through the same seam the +/// deploy-time path uses. Before this fix the runtime validators called the +/// single-arg InboundApiSchema.Parse(json) with NO resolver, so a schema +/// containing a $ref threw and every runtime +/// invocation of the method returned a 400 ("Invalid ... definitions ...") — a +/// deploy-passes/runtime-breaks defect. +/// +/// +/// 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 +/// ); and schemas WITHOUT a $ref are unaffected +/// when no resolver is supplied. +/// +/// +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); + } +}