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"
};
}
}
}