feat(m9/T32b): JSON Schema $ref resolver (lib seam, cycle/depth-guarded) + deploy-time dangling-ref block
This commit is contained in:
@@ -86,5 +86,12 @@ public enum ValidationCategory
|
||||
CrossCallViolation,
|
||||
MissingMetadata,
|
||||
ConnectionConfig,
|
||||
NativeAlarmSourceInvalid
|
||||
NativeAlarmSourceInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// M9-T32b: a script/method parameter or return JSON Schema contains a
|
||||
/// <c>{"$ref":"lib:Name"}</c> reference that could not be resolved against the
|
||||
/// shared-schema library — dangling, cyclic, or over-depth. Deploy-blocking.
|
||||
/// </summary>
|
||||
SchemaReference
|
||||
}
|
||||
|
||||
@@ -60,29 +60,131 @@ public sealed class InboundApiSchema
|
||||
/// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
|
||||
/// <returns>The parsed schema, or <c>null</c> when the input is empty.</returns>
|
||||
/// <exception cref="JsonException">The input is non-empty but not valid JSON, is a JSON scalar/null at the root, or the schema nesting exceeds <see cref="MaxDepth"/>.</exception>
|
||||
public static InboundApiSchema? Parse(string? json)
|
||||
public static InboundApiSchema? Parse(string? json) => Parse(json, resolveRef: null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stored definition string into an <see cref="InboundApiSchema"/>,
|
||||
/// resolving any <c>{"$ref":"lib:Name"}</c> library references (M9-T32b) through
|
||||
/// the caller-supplied <paramref name="resolveRef"/> seam.
|
||||
///
|
||||
/// <para>
|
||||
/// The pointer convention is <c>lib:Name</c>: a JSON object carrying a string
|
||||
/// <c>$ref</c> whose value begins with the <c>lib:</c> scheme prefix is a
|
||||
/// reference to the library entry named by the remainder. The seam maps that
|
||||
/// name (the part after <c>lib:</c>) to the referenced schema JSON, or returns
|
||||
/// <c>null</c> when the entry does not exist. Resolution is recursive (a
|
||||
/// referenced schema may itself contain <c>$ref</c>s) and guarded against
|
||||
/// cycles and excessive depth — a cycle or over-depth chain surfaces as a
|
||||
/// controlled <see cref="JsonException"/>, never a stack overflow.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A <c>$ref</c> the seam cannot resolve (returns <c>null</c>), a <c>$ref</c>
|
||||
/// with no resolver supplied, and a cyclic/over-depth <c>$ref</c> are all
|
||||
/// DANGLING — surfaced here as a <see cref="JsonException"/> so a caller using
|
||||
/// the throwing path treats them as a hard error. (For deploy-time validation
|
||||
/// that needs to COLLECT dangling refs rather than throw, use
|
||||
/// <see cref="ParseWithRefs"/>.)
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Schemas with no <c>$ref</c> parse identically to <see cref="Parse(string?)"/>;
|
||||
/// the resolver is never consulted for them, so behavior is unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="json">The definition JSON; null/whitespace yields <c>null</c>.</param>
|
||||
/// <param name="resolveRef">
|
||||
/// Optional reference-resolution seam mapping a <c>lib:Name</c> target's name to
|
||||
/// the referenced schema JSON (or <c>null</c> when not found). <c>null</c> means
|
||||
/// no resolver: a <c>$ref</c> then dangles. The seam keeps this Commons type free
|
||||
/// of any repository dependency — the caller (the validation layer) supplies it.
|
||||
/// </param>
|
||||
/// <returns>The parsed (and ref-resolved) schema, or <c>null</c> when the input is empty.</returns>
|
||||
/// <exception cref="JsonException">The input is invalid JSON, a root scalar/null, exceeds <see cref="MaxDepth"/>, or contains a dangling/cyclic/over-depth <c>$ref</c>.</exception>
|
||||
public static InboundApiSchema? Parse(string? json, Func<string, string?>? resolveRef)
|
||||
{
|
||||
var result = ParseWithRefs(json, resolveRef);
|
||||
if (result.UnresolvedRefs.Count > 0)
|
||||
{
|
||||
throw new JsonException(
|
||||
$"Schema contains unresolved $ref(s): {string.Join(", ", result.UnresolvedRefs)}.");
|
||||
}
|
||||
|
||||
return result.Schema;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a stored definition string into an <see cref="InboundApiSchema"/>,
|
||||
/// resolving <c>{"$ref":"lib:Name"}</c> library references through the
|
||||
/// caller-supplied <paramref name="resolveRef"/> seam, and COLLECTING (rather than
|
||||
/// throwing on) any references that cannot be resolved (M9-T32b).
|
||||
///
|
||||
/// <para>
|
||||
/// This is the deploy-time entry point: a dangling, cyclic, or over-depth
|
||||
/// <c>$ref</c> is reported in <see cref="SchemaParseResult.UnresolvedRefs"/> so the
|
||||
/// validation layer can surface it as a deploy-blocking error naming the missing
|
||||
/// reference, instead of aborting the whole parse. See <see cref="Parse(string?, Func{string, string?})"/>
|
||||
/// for the throwing variant and for the <c>lib:Name</c> pointer convention.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="json">The definition JSON; null/whitespace yields a result with a <c>null</c> schema and no unresolved refs.</param>
|
||||
/// <param name="resolveRef">
|
||||
/// Optional reference-resolution seam (see <see cref="Parse(string?, Func{string, string?})"/>).
|
||||
/// <c>null</c> means no resolver: any <c>$ref</c> is reported as unresolved.
|
||||
/// </param>
|
||||
/// <returns>The parsed schema (refs resolved where possible) plus the names of any references that could not be resolved.</returns>
|
||||
/// <exception cref="JsonException">The input is invalid JSON, a root scalar/null, or exceeds the structural <see cref="MaxDepth"/> for non-ref nesting.</exception>
|
||||
public static SchemaParseResult ParseWithRefs(string? json, Func<string, string?>? resolveRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
return new SchemaParseResult(null, []);
|
||||
}
|
||||
|
||||
var unresolved = new List<string>();
|
||||
// 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));
|
||||
|
||||
using var doc = JsonDocument.Parse(json, DocOptions);
|
||||
return doc.RootElement.ValueKind switch
|
||||
var schema = doc.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth: 0),
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth: 0, ctx),
|
||||
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
|
||||
_ => throw new JsonException("Type definition must be a JSON object (JSON Schema) or legacy parameter array."),
|
||||
};
|
||||
|
||||
return new SchemaParseResult(schema, unresolved);
|
||||
}
|
||||
|
||||
private static InboundApiSchema ParseSchema(JsonElement el, int depth)
|
||||
/// <summary>
|
||||
/// Carries the <c>$ref</c> resolution state threaded through the recursive parse:
|
||||
/// the caller-supplied resolver seam, the accumulator for unresolved references,
|
||||
/// and the set of references active on the current resolution path (the cycle
|
||||
/// guard). A <c>null</c> <see cref="Resolver"/> means no resolver was supplied —
|
||||
/// every <c>$ref</c> then dangles.
|
||||
/// </summary>
|
||||
private sealed record RefResolutionContext(
|
||||
Func<string, string?>? Resolver,
|
||||
List<string> Unresolved,
|
||||
HashSet<string> ActiveRefs);
|
||||
|
||||
private static InboundApiSchema ParseSchema(JsonElement el, int depth, RefResolutionContext ctx)
|
||||
{
|
||||
if (depth > MaxDepth)
|
||||
{
|
||||
throw new JsonException($"Schema nesting exceeds the maximum allowed depth of {MaxDepth}.");
|
||||
}
|
||||
|
||||
// $ref resolution (M9-T32b): a {"$ref":"lib:Name"} node is replaced by the
|
||||
// referenced schema, resolved through the caller-supplied seam. Dangling,
|
||||
// cyclic, and over-depth refs are recorded as unresolved (the caller decides
|
||||
// whether to throw or collect) and parse continues with a shape-only schema.
|
||||
if (TryReadLibRef(el, out var refName))
|
||||
{
|
||||
return ResolveLibRef(refName, depth, ctx);
|
||||
}
|
||||
|
||||
var type = el.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? NormalizeType(t.GetString())
|
||||
: "string";
|
||||
@@ -92,7 +194,7 @@ public sealed class InboundApiSchema
|
||||
InboundApiSchema? items = null;
|
||||
if (el.TryGetProperty("items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
items = ParseSchema(itemsEl, depth + 1);
|
||||
items = ParseSchema(itemsEl, depth + 1, ctx);
|
||||
}
|
||||
|
||||
return new InboundApiSchema { Type = "array", Items = items };
|
||||
@@ -122,7 +224,7 @@ public sealed class InboundApiSchema
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
var schema = prop.Value.ValueKind == JsonValueKind.Object
|
||||
? ParseSchema(prop.Value, depth + 1)
|
||||
? ParseSchema(prop.Value, depth + 1, ctx)
|
||||
: new InboundApiSchema { Type = "string" };
|
||||
fields.Add(new InboundApiSchemaField(prop.Name, requiredSet.Contains(prop.Name), schema));
|
||||
}
|
||||
@@ -134,6 +236,103 @@ public sealed class InboundApiSchema
|
||||
return new InboundApiSchema { Type = type };
|
||||
}
|
||||
|
||||
/// <summary>The <c>lib:</c> scheme prefix on a <c>$ref</c> value identifying a library reference.</summary>
|
||||
private const string LibRefScheme = "lib:";
|
||||
|
||||
/// <summary>The placeholder type for an unresolvable <c>$ref</c> node (dangling, cyclic, or over-depth).</summary>
|
||||
private const string UnresolvedRefType = "ref";
|
||||
|
||||
/// <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
|
||||
/// node that is not a <c>lib:</c> reference, so non-ref schemas take the normal path.
|
||||
/// </summary>
|
||||
/// <param name="el">The schema node to inspect.</param>
|
||||
/// <param name="refName">The resolved target name (after <c>lib:</c>) when this is a <c>lib:</c> ref; otherwise empty.</param>
|
||||
/// <returns><c>true</c> when <paramref name="el"/> is a <c>lib:</c> <c>$ref</c> node with a non-empty target name.</returns>
|
||||
private static bool TryReadLibRef(JsonElement el, out string refName)
|
||||
{
|
||||
refName = string.Empty;
|
||||
if (!el.TryGetProperty("$ref", out var refEl) || refEl.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = refEl.GetString();
|
||||
if (string.IsNullOrEmpty(raw) || !raw.StartsWith(LibRefScheme, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var name = raw[LibRefScheme.Length..].Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
refName = name;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <c>lib:Name</c> reference through the seam, parsing the referenced
|
||||
/// schema (which may itself contain <c>$ref</c>s). Dangling (seam returns null or no
|
||||
/// seam), cyclic (the name is already active on the current path), and over-depth
|
||||
/// references are recorded in the context's unresolved list and yield a shape-only
|
||||
/// placeholder schema so the parse terminates without throwing or overflowing.
|
||||
/// </summary>
|
||||
/// <param name="refName">The library entry name to resolve.</param>
|
||||
/// <param name="depth">The current structural depth (shared with the <see cref="MaxDepth"/> guard).</param>
|
||||
/// <param name="ctx">The active resolution context (seam, unresolved accumulator, cycle guard).</param>
|
||||
/// <returns>The resolved schema, or a placeholder <c>ref</c>-typed schema when unresolvable.</returns>
|
||||
private static InboundApiSchema ResolveLibRef(string refName, int depth, RefResolutionContext ctx)
|
||||
{
|
||||
// Depth guard: a long (even non-cyclic) ref chain is bounded by the same
|
||||
// structural ceiling as nested objects/arrays — terminate, never overflow.
|
||||
// The guard fires at `>= MaxDepth` (one level BEFORE ParseSchema's own
|
||||
// `> MaxDepth` throw) so an over-depth ref is COLLECTED as unresolved on the
|
||||
// ParseWithRefs path rather than aborting the whole parse with a throw.
|
||||
if (depth >= MaxDepth)
|
||||
{
|
||||
ctx.Unresolved.Add($"{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)");
|
||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||
}
|
||||
|
||||
var referenced = ctx.Resolver?.Invoke(refName);
|
||||
if (string.IsNullOrWhiteSpace(referenced))
|
||||
{
|
||||
// Dangling: the seam can't resolve it (or no seam was supplied).
|
||||
ctx.Unresolved.Add(refName);
|
||||
return new InboundApiSchema { Type = UnresolvedRefType };
|
||||
}
|
||||
|
||||
ctx.ActiveRefs.Add(refName);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(referenced, DocOptions);
|
||||
return doc.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => ParseSchema(doc.RootElement, depth + 1, ctx),
|
||||
JsonValueKind.Array => ParseLegacyArray(doc.RootElement),
|
||||
_ => throw new JsonException(
|
||||
$"Referenced schema 'lib:{refName}' must be a JSON object (JSON Schema) or legacy parameter array."),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Pop only AFTER the subtree is resolved so sibling refs to the same name
|
||||
// are allowed (a diamond is not a cycle) while a self-revisit on the path is caught.
|
||||
ctx.ActiveRefs.Remove(refName);
|
||||
}
|
||||
}
|
||||
|
||||
private static InboundApiSchema ParseLegacyArray(JsonElement arr)
|
||||
{
|
||||
var fields = new List<InboundApiSchemaField>();
|
||||
@@ -391,3 +590,20 @@ public sealed class InboundApiSchema
|
||||
/// <param name="Required">Whether the field must be present.</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);
|
||||
|
||||
/// <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.
|
||||
/// </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>
|
||||
public sealed record SchemaParseResult(
|
||||
InboundApiSchema? Schema,
|
||||
IReadOnlyList<string> UnresolvedRefs);
|
||||
|
||||
Reference in New Issue
Block a user