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