diff options
Diffstat (limited to 'src/SMAPI.Web/Controllers/JsonValidatorController.cs')
-rw-r--r-- | src/SMAPI.Web/Controllers/JsonValidatorController.cs | 349 |
1 files changed, 349 insertions, 0 deletions
diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs new file mode 100644 index 00000000..b2eb9a87 --- /dev/null +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Schema; +using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.ViewModels.JsonValidator; + +namespace StardewModdingAPI.Web.Controllers +{ + /// <summary>Provides a web UI for validating JSON schemas.</summary> + internal class JsonValidatorController : Controller + { + /********* + ** Fields + *********/ + /// <summary>The site config settings.</summary> + private readonly SiteConfig Config; + + /// <summary>The underlying Pastebin client.</summary> + private readonly IPastebinClient Pastebin; + + /// <summary>The underlying text compression helper.</summary> + private readonly IGzipHelper GzipHelper; + + /// <summary>The section URL for the schema validator.</summary> + private string SectionUrl => this.Config.JsonValidatorUrl; + + /// <summary>The supported JSON schemas (names indexed by ID).</summary> + private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> + { + ["none"] = "None", + ["manifest"] = "Manifest", + ["content-patcher"] = "Content Patcher" + }; + + /// <summary>The schema ID to use if none was specified.</summary> + private string DefaultSchemaID = "manifest"; + + /// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary> + private readonly string TransparentToken = "$transparent"; + + + /********* + ** Public methods + *********/ + /*** + ** Constructor + ***/ + /// <summary>Construct an instance.</summary> + /// <param name="siteConfig">The context config settings.</param> + /// <param name="pastebin">The Pastebin API client.</param> + /// <param name="gzipHelper">The underlying text compression helper.</param> + public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + { + this.Config = siteConfig.Value; + this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; + } + + /*** + ** Web UI + ***/ + /// <summary>Render the schema validator UI.</summary> + /// <param name="schemaName">The schema name with which to validate the JSON.</param> + /// <param name="id">The paste ID.</param> + [HttpGet] + [Route("json")] + [Route("json/{schemaName}")] + [Route("json/{schemaName}/{id}")] + public async Task<ViewResult> Index(string schemaName = null, string id = null) + { + schemaName = this.NormalizeSchemaName(schemaName); + + var result = new JsonValidatorModel(this.SectionUrl, id, schemaName, this.SchemaFormats); + if (string.IsNullOrWhiteSpace(id)) + return this.View("Index", result); + + // fetch raw JSON + PasteInfo paste = await this.GetAsync(id); + if (string.IsNullOrWhiteSpace(paste.Content)) + return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); + result.SetContent(paste.Content); + + // parse JSON + JToken parsed; + try + { + parsed = JToken.Parse(paste.Content, new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, + CommentHandling = CommentHandling.Load + }); + } + catch (JsonReaderException ex) + { + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message, ErrorType.None))); + } + + // format JSON + result.SetContent(parsed.ToString(Formatting.Indented)); + + // skip if no schema selected + if (schemaName == "none") + return this.View("Index", result); + + // load schema + JSchema schema; + { + FileInfo schemaFile = this.FindSchemaFile(schemaName); + if (schemaFile == null) + return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'.")); + schema = JSchema.Parse(System.IO.File.ReadAllText(schemaFile.FullName)); + } + + // get format doc URL + result.FormatUrl = this.GetExtensionField<string>(schema, "@documentationUrl"); + + // validate JSON + parsed.IsValid(schema, out IList<ValidationError> rawErrors); + var errors = rawErrors + .SelectMany(this.GetErrorModels) + .ToArray(); + return this.View("Index", result.AddErrors(errors)); + } + + /*** + ** JSON + ***/ + /// <summary>Save raw JSON data.</summary> + [HttpPost, AllowLargePosts] + [Route("json")] + public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) + { + if (request == null) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); + + // normalize schema name + string schemaName = this.NormalizeSchemaName(request.SchemaName); + + // get raw log text + string input = request.Content; + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); + + // upload log + input = this.GzipHelper.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input); + + // handle errors + if (!result.Success) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}")); + + // redirect to view + UriBuilder uri = new UriBuilder(new Uri(this.SectionUrl)); + uri.Path = $"{uri.Path.TrimEnd('/')}/{schemaName}/{result.ID}"; + return this.Redirect(uri.Uri.ToString()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Fetch raw text from Pastebin.</summary> + /// <param name="id">The Pastebin paste ID.</param> + private async Task<PasteInfo> GetAsync(string id) + { + PasteInfo response = await this.Pastebin.GetAsync(id); + response.Content = this.GzipHelper.DecompressString(response.Content); + return response; + } + + /// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary> + /// <param name="schemaName">The raw schema name to normalize.</param> + private string NormalizeSchemaName(string schemaName) + { + schemaName = schemaName?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(schemaName) + ? schemaName + : this.DefaultSchemaID; + } + + /// <summary>Get the schema file given its unique ID.</summary> + /// <param name="id">The schema ID.</param> + private FileInfo FindSchemaFile(string id) + { + // normalize ID + id = id?.Trim().ToLower(); + if (string.IsNullOrWhiteSpace(id)) + return null; + + // get matching file + DirectoryInfo schemaDir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); + foreach (FileInfo file in schemaDir.EnumerateFiles("*.json")) + { + if (file.Name.Equals($"{id}.json")) + return file; + } + + return null; + } + + /// <summary>Get view models representing a schema validation error and any child errors.</summary> + /// <param name="error">The error to represent.</param> + private IEnumerable<JsonValidatorErrorModel> GetErrorModels(ValidationError error) + { + // skip through transparent errors + if (this.IsTransparentError(error)) + { + foreach (var model in error.ChildErrors.SelectMany(this.GetErrorModels)) + yield return model; + yield break; + } + + // get message + string message = this.GetOverrideError(error); + if (message == null || message == this.TransparentToken) + message = this.FlattenErrorMessage(error); + + // build model + yield return new JsonValidatorErrorModel(error.LineNumber, error.Path, message, error.ErrorType); + } + + /// <summary>Get a flattened, human-readable message for a schema validation error and any child errors.</summary> + /// <param name="error">The error to represent.</param> + /// <param name="indent">The indentation level to apply for inner errors.</param> + private string FlattenErrorMessage(ValidationError error, int indent = 0) + { + // get override + string message = this.GetOverrideError(error); + if (message != null && message != this.TransparentToken) + return message; + + // skip through transparent errors + if (this.IsTransparentError(error)) + error = error.ChildErrors[0]; + + // get friendly representation of main error + message = error.Message; + switch (error.ErrorType) + { + case ErrorType.Const: + message = $"Invalid value. Found '{error.Value}', but expected '{error.Schema.Const}'."; + break; + + case ErrorType.Enum: + message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'."; + break; + + case ErrorType.Required: + message = $"Missing required fields: {string.Join(", ", (List<string>)error.Value)}."; + break; + } + + // add inner errors + foreach (ValidationError childError in error.ChildErrors) + message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.FlattenErrorMessage(childError, indent + 1); + return message; + } + + /// <summary>Get whether a validation error should be omitted in favor of its child errors in user-facing error messages.</summary> + /// <param name="error">The error to check.</param> + private bool IsTransparentError(ValidationError error) + { + if (!error.ChildErrors.Any()) + return false; + + string @override = this.GetOverrideError(error); + return + @override == this.TransparentToken + || (error.ErrorType == ErrorType.Then && @override == null); + } + + /// <summary>Get an override error from the JSON schema, if any.</summary> + /// <param name="error">The schema validation error.</param> + private string GetOverrideError(ValidationError error) + { + string GetRawOverrideError() + { + // get override errors + IDictionary<string, string> errors = this.GetExtensionField<Dictionary<string, string>>(error.Schema, "@errorMessages"); + if (errors == null) + return null; + errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase); + + // match error by type and message + foreach (var pair in errors) + { + if (!pair.Key.Contains(":")) + continue; + + string[] parts = pair.Key.Split(':', 2); + if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) + return pair.Value?.Trim(); + } + + // match by type + if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) + return message?.Trim(); + + return null; + } + + return GetRawOverrideError() + ?.Replace("@value", this.FormatValue(error.Value)); + } + + /// <summary>Get an extension field from a JSON schema.</summary> + /// <typeparam name="T">The field type.</typeparam> + /// <param name="schema">The schema whose extension fields to search.</param> + /// <param name="key">The case-insensitive field key.</param> + private T GetExtensionField<T>(JSchema schema, string key) + { + if (schema.ExtensionData != null) + { + foreach (var pair in schema.ExtensionData) + { + if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + return pair.Value.ToObject<T>(); + } + } + + return default; + } + + /// <summary>Format a schema value for display.</summary> + /// <param name="value">The value to format.</param> + private string FormatValue(object value) + { + switch (value) + { + case List<string> list: + return string.Join(", ", list); + + default: + return value?.ToString() ?? "null"; + } + } + } +} |