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 { /// <summary>Provides a web UI for validating JSON schemas.</summary> internal class JsonValidatorController : Controller { /********* ** Fields *********/ /// <summary>Provides access to raw data storage.</summary> private readonly IStorageProvider Storage; /// <summary>The supported JSON schemas (names indexed by ID).</summary> private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> { ["none"] = "None", ["manifest"] = "SMAPI: manifest", ["i18n"] = "SMAPI: translations (i18n)", ["content-patcher"] = "Content Patcher" }; /// <summary>The schema ID to use if none was specified.</summary> private string DefaultSchemaID = "none"; /// <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="storage">Provides access to raw data storage.</param> public JsonValidatorController(IStorageProvider storage) { this.Storage = storage; } /*** ** Web UI ***/ /// <summary>Render the schema validator UI.</summary> /// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param> /// <param name="id">The stored file ID.</param> /// <param name="operation">The operation to perform for the selected log ID. This can be 'edit', 'renew', or any other value to view.</param> [HttpGet] [Route("json")] [Route("json/{schemaName}")] [Route("json/{schemaName}/{id}")] [Route("json/{schemaName}/{id}/{operation}")] public async Task<ViewResult> 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; try { parsed = JToken.Parse(file.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), expiry: file.Expiry, uploadWarning: file.Warning); // 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", 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 = schemaName, id = result.ID })); } /********* ** Private methods *********/ /// <summary>Build a JSON validator model.</summary> /// <param name="pasteID">The stored file ID.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="isEditView">Whether to show the edit view.</param> private JsonValidatorModel GetModel(string pasteID, string schemaName, bool isEditView) { return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); } /// <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.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)); } /// <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 ((string curKey, JToken value) in schema.ExtensionData) { if (curKey.Equals(key, StringComparison.OrdinalIgnoreCase)) return 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) { return value switch { List<string> list => string.Join(", ", list), _ => value?.ToString() ?? "null" }; } } }