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)
|
||||
{
|
||||
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<string>();
|
||||
var unresolved = new List<UnresolvedSchemaRef>();
|
||||
// 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<string>(StringComparer.Ordinal));
|
||||
@@ -166,7 +166,7 @@ public sealed class InboundApiSchema
|
||||
/// </summary>
|
||||
private sealed record RefResolutionContext(
|
||||
Func<string, string?>? Resolver,
|
||||
List<string> Unresolved,
|
||||
List<UnresolvedSchemaRef> Unresolved,
|
||||
HashSet<string> ActiveRefs);
|
||||
|
||||
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>
|
||||
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>
|
||||
/// 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
|
||||
@@ -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
|
||||
/// <param name="Schema">The recursive type schema the field's value must satisfy.</param>
|
||||
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>
|
||||
/// The outcome of <see cref="InboundApiSchema.ParseWithRefs"/> (M9-T32b): the parsed
|
||||
/// 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
|
||||
/// <c>null</c> or no seam was supplied), cyclic, or over-depth. A non-empty
|
||||
/// <see cref="UnresolvedRefs"/> is the deploy-blocking signal the validation layer acts on.
|
||||
/// references that could NOT be resolved — dangling (the seam returned <c>null</c> or no
|
||||
/// seam was supplied), cyclic, or over-depth. A non-empty <see cref="UnresolvedReferences"/>
|
||||
/// is the deploy-/runtime-blocking signal the validation layer acts on.
|
||||
/// </summary>
|
||||
/// <param name="Schema">The parsed schema, or <c>null</c> when the input was empty.</param>
|
||||
/// <param name="UnresolvedRefs">
|
||||
/// The reference targets that could not be resolved, each annotated with the reason for
|
||||
/// cyclic/over-depth cases (e.g. <c>"Foo (cyclic reference)"</c>). Empty when every
|
||||
/// reference resolved.
|
||||
/// <param name="UnresolvedReferences">
|
||||
/// The structured reference targets that could not be resolved — each carrying the bare
|
||||
/// <see cref="UnresolvedSchemaRef.Name"/> separate from an optional
|
||||
/// <see cref="UnresolvedSchemaRef.Reason"/>. Empty when every reference resolved.
|
||||
/// </param>
|
||||
public sealed record SchemaParseResult(
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user