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 { /// Provides a web UI for validating JSON schemas. internal class JsonValidatorController : Controller { /********* ** Fields *********/ /// The site config settings. private readonly SiteConfig Config; /// The underlying Pastebin client. private readonly IPastebinClient Pastebin; /// The underlying text compression helper. private readonly IGzipHelper GzipHelper; /// The section URL for the schema validator. private string SectionUrl => this.Config.JsonValidatorUrl; /// The supported JSON schemas (names indexed by ID). private readonly IDictionary SchemaFormats = new Dictionary { ["none"] = "None", ["manifest"] = "Manifest", ["content-patcher"] = "Content Patcher" }; /// The schema ID to use if none was specified. private string DefaultSchemaID = "manifest"; /// A token in an error message which indicates that the child errors should be displayed instead. private readonly string TransparentToken = "$transparent"; /********* ** Public methods *********/ /*** ** Constructor ***/ /// Construct an instance. /// The context config settings. /// The Pastebin API client. /// The underlying text compression helper. public JsonValidatorController(IOptions siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { this.Config = siteConfig.Value; this.Pastebin = pastebin; this.GzipHelper = gzipHelper; } /*** ** Web UI ***/ /// Render the schema validator UI. /// The schema name with which to validate the JSON. /// The paste ID. [HttpGet] [Route("json")] [Route("json/{schemaName}")] [Route("json/{schemaName}/{id}")] public async Task 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(schema, "@documentationUrl"); // validate JSON parsed.IsValid(schema, out IList rawErrors); var errors = rawErrors .SelectMany(this.GetErrorModels) .ToArray(); return this.View("Index", result.AddErrors(errors)); } /*** ** JSON ***/ /// Save raw JSON data. [HttpPost, AllowLargePosts] [Route("json")] public async Task 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 *********/ /// Fetch raw text from Pastebin. /// The Pastebin paste ID. private async Task GetAsync(string id) { PasteInfo response = await this.Pastebin.GetAsync(id); response.Content = this.GzipHelper.DecompressString(response.Content); return response; } /// Get a normalized schema name, or the if blank. /// The raw schema name to normalize. private string NormalizeSchemaName(string schemaName) { schemaName = schemaName?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(schemaName) ? schemaName : this.DefaultSchemaID; } /// Get the schema file given its unique ID. /// The schema ID. 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; } /// Get view models representing a schema validation error and any child errors. /// The error to represent. private IEnumerable 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); } /// Get a flattened, human-readable message for a schema validation error and any child errors. /// The error to represent. /// The indentation level to apply for inner errors. 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)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; } /// Get whether a validation error should be omitted in favor of its child errors in user-facing error messages. /// The error to check. 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); } /// Get an override error from the JSON schema, if any. /// The schema validation error. private string GetOverrideError(ValidationError error) { string GetRawOverrideError() { // get override errors IDictionary errors = this.GetExtensionField>(error.Schema, "@errorMessages"); if (errors == null) return null; errors = new Dictionary(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)); } /// Get an extension field from a JSON schema. /// The field type. /// The schema whose extension fields to search. /// The case-insensitive field key. private T GetExtensionField(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(); } } return default; } /// Format a schema value for display. /// The value to format. private string FormatValue(object value) { switch (value) { case List list: return string.Join(", ", list); default: return value?.ToString() ?? "null"; } } } }