SchemaAssert.cs 3.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. using System.Collections.Concurrent;
  2. using System.Globalization;
  3. using System.Text.Json;
  4. using Json.Schema;
  5. using YamlDotNet.RepresentationModel;
  6. namespace Tests.Mcp;
  7. /// <summary>
  8. /// Asserts YAML the MCP tools hand out (or persist) satisfies the published
  9. /// RackPeek schema, so the tool surface cannot drift away from the contract the
  10. /// rest of the world imports. Same approach as Tests.Discovery.
  11. /// </summary>
  12. public static class SchemaAssert {
  13. // JsonSchema.Net keeps a process-wide registry keyed on $id, so loading the same
  14. // schema from two test classes at once races. Load each one exactly once.
  15. private static readonly ConcurrentDictionary<int, Lazy<JsonSchema>> _schemas = new();
  16. public static void ConformsToSchema(string yaml, int version = 4) {
  17. JsonSchema schema = _schemas.GetOrAdd(version, v => new Lazy<JsonSchema>(() =>
  18. JsonSchema.FromText(
  19. File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "schemas", $"schema.v{v}.json"))),
  20. LazyThreadSafetyMode.ExecutionAndPublication)).Value;
  21. EvaluationResults results = schema.Evaluate(
  22. ToJson(yaml),
  23. new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical });
  24. if (results.IsValid)
  25. return;
  26. var errors = new List<string>();
  27. Collect(results, errors);
  28. Assert.Fail($"YAML does not match schema v{version}:{Environment.NewLine}"
  29. + string.Join(Environment.NewLine, errors.Distinct())
  30. + Environment.NewLine + Environment.NewLine + yaml);
  31. }
  32. private static void Collect(EvaluationResults node, List<string> errors) {
  33. if (node.Errors != null)
  34. foreach (KeyValuePair<string, string> error in node.Errors)
  35. errors.Add($"{node.InstanceLocation}: {error.Value}");
  36. if (node.Details != null)
  37. foreach (EvaluationResults child in node.Details)
  38. Collect(child, errors);
  39. }
  40. private static JsonElement ToJson(string yaml) {
  41. var stream = new YamlStream();
  42. stream.Load(new StringReader(yaml));
  43. using var document = JsonDocument.Parse(Convert(stream.Documents[0].RootNode));
  44. return document.RootElement.Clone();
  45. }
  46. private static string Convert(YamlNode node) {
  47. switch (node) {
  48. case YamlScalarNode scalar:
  49. if (scalar.Style is YamlDotNet.Core.ScalarStyle.SingleQuoted
  50. or YamlDotNet.Core.ScalarStyle.DoubleQuoted)
  51. return JsonSerializer.Serialize(scalar.Value);
  52. if (int.TryParse(scalar.Value, out var i))
  53. return i.ToString();
  54. if (double.TryParse(scalar.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
  55. return d.ToString(CultureInfo.InvariantCulture);
  56. if (bool.TryParse(scalar.Value, out var b))
  57. return b.ToString().ToLowerInvariant();
  58. return JsonSerializer.Serialize(scalar.Value);
  59. case YamlSequenceNode sequence:
  60. return "[" + string.Join(",", sequence.Children.Select(Convert)) + "]";
  61. case YamlMappingNode mapping:
  62. return "{" + string.Join(",", mapping.Children.Select(kvp =>
  63. JsonSerializer.Serialize(((YamlScalarNode)kvp.Key).Value) + ":" + Convert(kvp.Value))) + "}";
  64. default:
  65. return "null";
  66. }
  67. }
  68. }