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 Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Schema; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.ViewModels.JsonValidator; namespace StardewModdingAPI.Web.Controllers { /// Provides a web UI for validating JSON schemas. internal class JsonValidatorController : Controller { /********* ** Fields *********/ /// Provides access to raw data storage. private readonly IStorageProvider Storage; /// The supported JSON schemas (names indexed by ID). private readonly IDictionary SchemaFormats = new Dictionary { ["none"] = "None", ["manifest"] = "SMAPI: manifest", ["i18n"] = "SMAPI: translations (i18n)", ["content-patcher"] = "Content Patcher" }; /// The schema ID to use if none was specified. private string DefaultSchemaID = "none"; /// 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. /// Provides access to raw data storage. public JsonValidatorController(IStorageProvider storage) { this.Storage = storage; } /*** ** Web UI ***/ /// Render the schema validator UI. /// The schema name with which to validate the JSON, or 'edit' to return to the edit screen. /// The stored file ID. /// The operation to perform for the selected log ID. This can be 'edit', 'renew', or any other value to view. [HttpGet] [Route("json")] [Route("json/{schemaName}")] [Route("json/{schemaName}/{id}")] [Route("json/{schemaName}/{id}/{operation}")] public async Task Index(string? schemaName = null, string? id = null, string? operation = null) { // parse arguments schemaName = this.NormalizeSchemaName(schemaName); operation = operation?.Trim().ToLower(); bool hasId = !string.IsNullOrWhiteSpace(id); bool isEditView = !hasId || operation == "edit"; bool renew = operation == "renew"; // build result model var result = this.GetModel(id, schemaName, isEditView); if (!hasId) return this.View("Index", result); // fetch raw JSON StoredFileInfo file = await this.Storage.GetAsync(id!, renew); if (string.IsNullOrWhiteSpace(file.Content)) return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning); // skip parsing if we're going to the edit screen if (isEditView) return this.View("Index", result); // parse JSON JToken parsed; { // load raw JSON var settings = new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, CommentHandling = CommentHandling.Load }; try { parsed = JToken.Parse(file.Content, settings); } catch (JsonReaderException ex) { return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path!, ex.Message, ErrorType.None))); } // format JSON string formatted = parsed.ToString(Formatting.Indented); result.SetContent(formatted, expiry: file.Expiry, uploadWarning: file.Warning); parsed = JToken.Parse(formatted); // update line number references } // 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(await System.IO.File.ReadAllTextAsync(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", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid.")); // normalize schema name string schemaName = this.NormalizeSchemaName(request.SchemaName); // get raw text string? input = request.Content; if (string.IsNullOrWhiteSpace(input)) return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty.")); // upload file UploadResult result = await this.Storage.SaveAsync(input); if (!result.Succeeded) return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null).SetUploadError(result.UploadError)); // redirect to view return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })!); } /********* ** Private methods *********/ /// Build a JSON validator model. /// The stored file ID. /// The schema name with which the JSON was validated. /// Whether to show the edit view. private JsonValidatorModel GetModel(string? pasteID, string? schemaName, bool isEditView) { return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); } /// 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(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 (JsonValidatorErrorModel 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.OrdinalIgnoreCase); // match error by type and message foreach ((string target, string? errorMessage) in errors) { if (!target.Contains(":")) continue; string[] parts = target.Split(':', 2); if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.OrdinalIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) return errorMessage?.Trim(); } // match by type return errors.TryGetValue(error.ErrorType.ToString(), out string? message) ? message?.Trim() : 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) { foreach ((string curKey, JToken value) in schema.ExtensionData) { if (curKey.Equals(key, StringComparison.OrdinalIgnoreCase)) return value.ToObject(); } return default; } /// Format a schema value for display. /// The value to format. private string FormatValue(object? value) { return value switch { List list => string.Join(", ", list), _ => value?.ToString() ?? "null" }; } } }