diff --git a/docs/technical/web.md b/docs/technical/web.md index 50799e00..9884fefc 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -6,6 +6,7 @@ and update check API. ## Contents * [Overview](#overview) * [Log parser](#log-parser) + * [JSON validator](#json-validator) * [Web API](#web-api) * [For SMAPI developers](#for-smapi-developers) * [Local development](#local-development) @@ -16,9 +17,41 @@ The `SMAPI.Web` project provides an API and web UI hosted at `*.smapi.io`. ### Log parser The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are -persisted in a compressed form to Pastebin. +persisted in a compressed form to Pastebin. The log parser lives at https://log.smapi.io. -The log parser lives at https://log.smapi.io. +### JSON validator +The JSON validator provides a web UI for uploading and sharing JSON files, and validating them +as plain JSON or against a predefined format like `manifest.json` or Content Patcher's +`content.json`. The JSON validator lives at https://json.smapi.io. + +Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/) +format, with some special properties: +* The root schema may have a `@documentationURL` field, which is the URL to the user-facing + documentation for the format (if any). +* Any part of the schema can define an `@errorMessages` field, which specifies user-friendly errors + which override the auto-generated messages. These are indexed by error type. For example: + ```js + "pattern": "^[a-zA-Z0-9_.-]+\\.dll$", + "@errorMessages": { + "pattern": "Invalid value; must be a filename ending with .dll." + } + ``` + +You can also reference these schemas in your JSON file directly using the `$schema` field, for +text editors that support schema validation. For example: +```js +{ + "$schema": "https://smapi.io/schemas/manifest.json", + "Name": "Some mod", + ... +} +``` + +Current schemas: + +format | schema URL +------ | ---------- +[SMAPI `manifest.json`](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest) | https://smapi.io/schemas/manifest.json ### Web API SMAPI provides a web API at `api.smapi.io` for use by SMAPI and external tools. The URL includes a diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs new file mode 100644 index 00000000..7b755d3b --- /dev/null +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +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" + }; + + /// The schema ID to use if none was specified. + private string DefaultSchemaID = "manifest"; + + + /********* + ** 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.NormaliseSchemaName(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); + } + catch (JsonReaderException ex) + { + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message))); + } + + // 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 + .Select(error => new JsonValidatorErrorModel(error.LineNumber, error.Path, this.GetFlattenedError(error))) + .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.")); + + // normalise schema name + string schemaName = this.NormaliseSchemaName(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 flattened, human-readable message representing a schema validation error. + /// The error to represent. + /// The indentation level to apply for inner errors. + private string GetFlattenedError(ValidationError error, int indent = 0) + { + // get override error + string message = this.GetOverrideError(error.Schema, error.ErrorType); + if (message != null) + return message; + + // get friendly representation of main error + message = error.Message; + switch (error.ErrorType) + { + 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.GetFlattenedError(childError, indent + 1); + return message; + } + + /// Get a normalised schema name, or the if blank. + /// The raw schema name to normalise. + private string NormaliseSchemaName(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) + { + // normalise 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 an override error from the JSON schema, if any. + /// The schema or subschema that raised the error. + /// The error type. + private string GetOverrideError(JSchema schema, ErrorType errorType) + { + // get override errors + IDictionary errors = this.GetExtensionField>(schema, "@errorMessages"); + if (errors == null) + return null; + errors = new Dictionary(errors, StringComparer.InvariantCultureIgnoreCase); + + // get matching error + return errors.TryGetValue(errorType.ToString(), out string errorPhrase) + ? errorPhrase + : null; + } + + /// 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; + } + } +} diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 21e4a56f..0556a81e 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,13 +1,11 @@ using System; -using System.IO; -using System.IO.Compression; using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.LogParsing; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -27,9 +25,8 @@ namespace StardewModdingAPI.Web.Controllers /// The underlying Pastebin client. private readonly IPastebinClient Pastebin; - /// The first bytes in a valid zip file. - /// See . - private const uint GzipLeadBytes = 0x8b1f; + /// The underlying text compression helper. + private readonly IGzipHelper GzipHelper; /********* @@ -41,10 +38,12 @@ namespace StardewModdingAPI.Web.Controllers /// Construct an instance. /// The context config settings. /// The Pastebin API client. - public LogParserController(IOptions siteConfig, IPastebinClient pastebin) + /// The underlying text compression helper. + public LogParserController(IOptions siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { this.Config = siteConfig.Value; this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; } /*** @@ -84,8 +83,8 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." }); // upload log - input = this.CompressString(input); - SavePasteResult result = await this.Pastebin.PostAsync(input); + input = this.GzipHelper.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", input); // handle errors if (!result.Success) @@ -106,75 +105,8 @@ namespace StardewModdingAPI.Web.Controllers private async Task GetAsync(string id) { PasteInfo response = await this.Pastebin.GetAsync(id); - response.Content = this.DecompressString(response.Content); + response.Content = this.GzipHelper.DecompressString(response.Content); return response; } - - /// Compress a string. - /// The text to compress. - /// Derived from . - private string CompressString(string text) - { - // get raw bytes - byte[] buffer = Encoding.UTF8.GetBytes(text); - - // compressed - byte[] compressedData; - using (MemoryStream stream = new MemoryStream()) - { - using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) - zipStream.Write(buffer, 0, buffer.Length); - - stream.Position = 0; - compressedData = new byte[stream.Length]; - stream.Read(compressedData, 0, compressedData.Length); - } - - // prefix length - byte[] zipBuffer = new byte[compressedData.Length + 4]; - Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); - Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); - - // return string representation - return Convert.ToBase64String(zipBuffer); - } - - /// Decompress a string. - /// The compressed text. - /// Derived from . - private string DecompressString(string rawText) - { - // get raw bytes - byte[] zipBuffer; - try - { - zipBuffer = Convert.FromBase64String(rawText); - } - catch - { - return rawText; // not valid base64, wasn't compressed by the log parser - } - - // skip if not gzip - if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes) - return rawText; - - // decompress - using (MemoryStream memoryStream = new MemoryStream()) - { - // read length prefix - int dataLength = BitConverter.ToInt32(zipBuffer, 0); - memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); - - // read data - byte[] buffer = new byte[dataLength]; - memoryStream.Position = 0; - using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) - gZipStream.Read(buffer, 0, buffer.Length); - - // return original string - return Encoding.UTF8.GetString(buffer); - } - } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index 630dfb76..a635abe3 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin Task GetAsync(string id); /// Save a paste to Pastebin. + /// The paste name. /// The paste content. - Task PostAsync(string content); + Task PostAsync(string name, string content); } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 1e46f2dc..2e8a8c68 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -67,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin } /// Save a paste to Pastebin. + /// The paste name. /// The paste content. - public async Task PostAsync(string content) + public async Task PostAsync(string name, string content) { try { @@ -85,7 +86,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin api_user_key = this.UserKey, api_dev_key = this.DevKey, api_paste_private = 1, // unlisted - api_paste_name = $"SMAPI log {DateTime.UtcNow:s}", + api_paste_name = name, api_paste_expire_date = "N", // never expire api_paste_code = content })) diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs new file mode 100644 index 00000000..cc8f4737 --- /dev/null +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace StardewModdingAPI.Web.Framework.Compression +{ + /// Handles GZip compression logic. + internal class GzipHelper : IGzipHelper + { + /********* + ** Fields + *********/ + /// The first bytes in a valid zip file. + /// See . + private const uint GzipLeadBytes = 0x8b1f; + + + /********* + ** Public methods + *********/ + /// Compress a string. + /// The text to compress. + /// Derived from . + public string CompressString(string text) + { + // get raw bytes + byte[] buffer = Encoding.UTF8.GetBytes(text); + + // compressed + byte[] compressedData; + using (MemoryStream stream = new MemoryStream()) + { + using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) + zipStream.Write(buffer, 0, buffer.Length); + + stream.Position = 0; + compressedData = new byte[stream.Length]; + stream.Read(compressedData, 0, compressedData.Length); + } + + // prefix length + byte[] zipBuffer = new byte[compressedData.Length + 4]; + Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); + Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); + + // return string representation + return Convert.ToBase64String(zipBuffer); + } + + /// Decompress a string. + /// The compressed text. + /// Derived from . + public string DecompressString(string rawText) + { + // get raw bytes + byte[] zipBuffer; + try + { + zipBuffer = Convert.FromBase64String(rawText); + } + catch + { + return rawText; // not valid base64, wasn't compressed by the log parser + } + + // skip if not gzip + if (BitConverter.ToUInt16(zipBuffer, 4) != GzipHelper.GzipLeadBytes) + return rawText; + + // decompress + using (MemoryStream memoryStream = new MemoryStream()) + { + // read length prefix + int dataLength = BitConverter.ToInt32(zipBuffer, 0); + memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); + + // read data + byte[] buffer = new byte[dataLength]; + memoryStream.Position = 0; + using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + gZipStream.Read(buffer, 0, buffer.Length); + + // return original string + return Encoding.UTF8.GetString(buffer); + } + } + } +} diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs new file mode 100644 index 00000000..a000865e --- /dev/null +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI.Web.Framework.Compression +{ + /// Handles GZip compression logic. + internal interface IGzipHelper + { + /********* + ** Methods + *********/ + /// Compress a string. + /// The text to compress. + string CompressString(string text); + + /// Decompress a string. + /// The compressed text. + string DecompressString(string rawText); + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index d89a4260..bc6e868a 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// The root URL for the log parser. public string LogParserUrl { get; set; } + /// The root URL for the JSON validator. + public string JsonValidatorUrl { get; set; } + /// The root URL for the mod list. public string ModListUrl { get; set; } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 26b29fce..98517818 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -26,6 +26,7 @@ + diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 33737235..de45b8a4 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -20,6 +20,7 @@ using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.RewriteRules; @@ -149,6 +150,9 @@ namespace StardewModdingAPI.Web devKey: api.PastebinDevKey )); } + + // init helpers + services.AddSingleton(new GzipHelper()); } /// The method called by the runtime to configure the HTTP request pipeline. @@ -199,7 +203,7 @@ namespace StardewModdingAPI.Web redirects.Add(new ConditionalRewriteSubdomainRule( shouldRewrite: req => req.Host.Host != "localhost" - && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) + && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("json.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) && !req.Path.StartsWithSegments("/content") && !req.Path.StartsWithSegments("/favicon.ico") )); diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs new file mode 100644 index 00000000..f9497a38 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs @@ -0,0 +1,36 @@ +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// The view model for a JSON validator error. + public class JsonValidatorErrorModel + { + /********* + ** Accessors + *********/ + /// The line number on which the error occurred. + public int Line { get; set; } + + /// The field path in the JSON file where the error occurred. + public string Path { get; set; } + + /// A human-readable description of the error. + public string Message { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public JsonValidatorErrorModel() { } + + /// Construct an instance. + /// The line number on which the error occurred. + /// The field path in the JSON file where the error occurred. + /// A human-readable description of the error. + public JsonValidatorErrorModel(int line, string path, string message) + { + this.Line = line; + this.Path = path; + this.Message = message; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs new file mode 100644 index 00000000..2d13bf23 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// The view model for the JSON validator page. + public class JsonValidatorModel + { + /********* + ** Accessors + *********/ + /// The root URL for the log parser controller. + public string SectionUrl { get; set; } + + /// The paste ID. + public string PasteID { get; set; } + + /// The schema name with which the JSON was validated. + public string SchemaName { get; set; } + + /// The supported JSON schemas (names indexed by ID). + public readonly IDictionary SchemaFormats; + + /// The validated content. + public string Content { get; set; } + + /// The schema validation errors, if any. + public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; + + /// An error which occurred while uploading the JSON to Pastebin. + public string UploadError { get; set; } + + /// An error which occurred while parsing the JSON. + public string ParseError { get; set; } + + /// A web URL to the user-facing format documentation. + public string FormatUrl { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public JsonValidatorModel() { } + + /// Construct an instance. + /// The root URL for the log parser controller. + /// The paste ID. + /// The schema name with which the JSON was validated. + /// The supported JSON schemas (names indexed by ID). + public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary schemaFormats) + { + this.SectionUrl = sectionUrl; + this.PasteID = pasteID; + this.SchemaName = schemaName; + this.SchemaFormats = schemaFormats; + } + + /// Set the validated content. + /// The validated content. + public JsonValidatorModel SetContent(string content) + { + this.Content = content; + + return this; + } + + /// Set the error which occurred while uploading the log to Pastebin. + /// The error message. + public JsonValidatorModel SetUploadError(string error) + { + this.UploadError = error; + + return this; + } + + /// Set the error which occurred while parsing the JSON. + /// The error message. + public JsonValidatorModel SetParseError(string error) + { + this.ParseError = error; + + return this; + } + + /// Add validation errors to the response. + /// The schema validation errors. + public JsonValidatorModel AddErrors(params JsonValidatorErrorModel[] errors) + { + this.Errors = this.Errors.Concat(errors).ToArray(); + + return this; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs new file mode 100644 index 00000000..c8e851bf --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// The view model for a JSON validation request. + public class JsonValidatorRequestModel + { + /********* + ** Accessors + *********/ + /// The schema name with which to validate the JSON. + public string SchemaName { get; set; } + + /// The raw content to validate. + public string Content { get; set; } + } +} diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml new file mode 100644 index 00000000..5c3168e5 --- /dev/null +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -0,0 +1,131 @@ +@using StardewModdingAPI.Web.ViewModels.JsonValidator +@model JsonValidatorModel + +@{ + ViewData["Title"] = "JSON validator"; +} + +@section Head { + @if (Model.PasteID != null) + { + + } + + + + + + + + + +} + +@* upload result banner *@ +@if (Model.UploadError != null) +{ + +} +else if (Model.ParseError != null) +{ + +} +else if (Model.PasteID != null) +{ + +} + +@* upload new file *@ +@if (Model.Content == null) +{ +

Upload a JSON file

+
+
    +
  1. + Choose the JSON format:
    + +
  2. +
  3. + Drag the file onto this textbox (or paste the text in):
    + +
  4. +
  5. + Click this button:
    + +
  6. +
+
+} + +@* validation results *@ +@if (Model.Content != null) +{ +
+ @if (Model.UploadError == null) + { +
+ Change JSON format: + +
+ +

Validation errors

+ @if (Model.FormatUrl != null) + { +

See format documentation.

+ } + + @if (Model.Errors.Any()) + { + + + + + + + + @foreach (JsonValidatorErrorModel error in Model.Errors) + { + + + + + + } +
LineFieldError
@error.Line@error.Path@error.Message
+ } + else + { +

No errors found.

+ } + } + +

Raw content

+
@Model.Content
+
+} diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 4c602b29..9911ef0e 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -16,9 +16,14 @@

SMAPI

+ +

Tools

+
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index e6b4a1b1..baf7efb7 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -12,6 +12,7 @@ "RootUrl": "http://localhost:59482/", "ModListUrl": "http://localhost:59482/mods/", "LogParserUrl": "http://localhost:59482/log/", + "JsonValidatorUrl": "http://localhost:59482/json/", "BetaEnabled": false, "BetaBlurb": null }, diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index f9777f87..a440cf42 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -19,6 +19,7 @@ "RootUrl": null, // see top note "ModListUrl": null, // see top note "LogParserUrl": null, // see top note + "JsonValidatorUrl": null, // see top note "BetaEnabled": null, // see top note "BetaBlurb": null // see top note }, diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css new file mode 100644 index 00000000..d4a43032 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css @@ -0,0 +1,102 @@ +/********* +** Main layout +*********/ +#content { + max-width: 100%; +} + +#output { + padding: 10px; + overflow: auto; +} + +#output table td { + font-family: monospace; +} + +#output table tr th, +#output table tr td { + padding: 0 0.75rem; + white-space: pre-wrap; +} + + +/********* +** Result banner +*********/ +.banner { + border: 2px solid gray; + border-radius: 5px; + margin-top: 1em; + padding: 1em; +} + +.banner.success { + border-color: green; + background: #CFC; +} + +.banner.error { + border-color: red; + background: #FCC; +} + +/********* +** Validation results +*********/ +.table { + border-bottom: 1px dashed #888888; + margin-bottom: 5px; +} + +#metadata th, #metadata td { + text-align: left; + padding-right: 0.7em; +} + +.table { + border: 1px solid #000000; + background: #ffffff; + border-radius: 5px; + border-spacing: 1px; + overflow: hidden; + cursor: default; + box-shadow: 1px 1px 1px 1px #dddddd; +} + +.table tr { + background: #eee; +} + +.table tr:nth-child(even) { + background: #fff; +} + +#output div.sunlight-line-highlight-active { + background-color: #eeeacc; +} + +/********* +** Upload form +*********/ +#input { + width: 100%; + height: 20em; + max-height: 70%; + margin: auto; + box-sizing: border-box; + border-radius: 5px; + border: 1px solid #000088; + outline: none; + box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); +} + +#submit { + font-size: 1.5em; + border-radius: 5px; + outline: none; + box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2); + cursor: pointer; + border: 1px solid #008800; + background-color: #cfc; +} diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index 57eeee88..dcc7a798 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -73,7 +73,7 @@ a { } #sidebar h4 { - margin: 0 0 0.2em 0; + margin: 1.5em 0 0.2em 0; width: 10em; border-bottom: 1px solid #CCC; font-size: 0.8em; diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js new file mode 100644 index 00000000..265e0c5e --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js @@ -0,0 +1,179 @@ +/* globals $ */ + +var smapi = smapi || {}; + +/** + * Manages the logic for line range selections. + * @param {int} maxLines The maximum number of lines in the content. + */ +smapi.LineNumberRange = function (maxLines) { + var self = this; + + /** + * @var {int} minLine The first line in the selection, or null if no lines selected. + */ + self.minLine = null; + + /** + * @var {int} maxLine The last line in the selection, or null if no lines selected. + */ + self.maxLine = null; + + /** + * Parse line numbers from a URL hash. + * @param {string} hash the URL hash to parse. + */ + self.parseFromUrlHash = function (hash) { + self.minLine = null; + self.maxLine = null; + + // parse hash + var hashParts = hash.match(/^#L(\d+)(?:-L(\d+))?$/); + if (!hashParts || hashParts.length <= 1) + return; + + // extract min/max lines + self.minLine = parseInt(hashParts[1]); + self.maxLine = parseInt(hashParts[2]) || self.minLine; + }; + + /** + * Generate a URL hash for the current line range. + * @returns {string} The generated URL hash. + */ + self.buildHash = function() { + if (!self.minLine) + return ""; + else if (self.minLine === self.maxLine) + return "#L" + self.minLine; + else + return "#L" + self.minLine + "-L" + self.maxLine; + } + + /** + * Get a list of all selected lines. + * @returns {Array} The selected line numbers. + */ + self.getLinesSelected = function() { + // format + if (!self.minLine) + return []; + + var lines = []; + for (var i = self.minLine; i <= self.maxLine; i++) + lines.push(i); + return lines; + }; + + return self; +}; + +/** + * UI logic for the JSON validator page. + * @param {any} sectionUrl The base JSON validator page URL. + * @param {any} pasteID The Pastebin paste ID for the content being viewed, if any. + */ +smapi.jsonValidator = function (sectionUrl, pasteID) { + /** + * The original content element. + */ + var originalContent = $("#raw-content").clone(); + + /** + * The currently highlighted lines. + */ + var selection = new smapi.LineNumberRange(); + + /** + * Rebuild the syntax-highlighted element. + */ + var formatCode = function () { + // reset if needed + $(".sunlight-container").replaceWith(originalContent.clone()); + + // apply default highlighting + Sunlight.highlightAll({ + lineHighlight: selection.getLinesSelected() + }); + + // fix line links + $(".sunlight-line-number-margin a").each(function() { + var link = $(this); + var lineNumber = parseInt(link.text()); + link + .attr("id", "L" + lineNumber) + .attr("href", "#L" + lineNumber) + .removeAttr("name") + .data("line-number", lineNumber); + }); + }; + + /** + * Scroll the page so the selected range is visible. + */ + var scrollToRange = function() { + if (!selection.minLine) + return; + + var targetLine = Math.max(1, selection.minLine - 5); + $("#L" + targetLine).get(0).scrollIntoView(); + }; + + /** + * Initialise the JSON validator page. + */ + var init = function () { + // set initial code formatting + selection.parseFromUrlHash(location.hash); + formatCode(); + scrollToRange(); + + // update code formatting on hash change + $(window).on("hashchange", function() { + selection.parseFromUrlHash(location.hash); + formatCode(); + scrollToRange(); + }); + + // change format + $("#output #format").on("change", function() { + var schemaName = $(this).val(); + location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString(); + }); + + // upload form + var input = $("#input"); + if (input.length) { + // disable submit if it's empty + var toggleSubmit = function () { + var hasText = !!input.val().trim(); + submit.prop("disabled", !hasText); + }; + input.on("input", toggleSubmit); + toggleSubmit(); + + // drag & drop file + input.on({ + 'dragover dragenter': function (e) { + e.preventDefault(); + e.stopPropagation(); + }, + 'drop': function (e) { + var dataTransfer = e.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.files.length) { + e.preventDefault(); + e.stopPropagation(); + var file = dataTransfer.files[0]; + var reader = new FileReader(); + reader.onload = $.proxy(function (file, $input, event) { + $input.val(event.target.result); + toggleSubmit(); + }, this, file, $("#input")); + reader.readAsText(file); + } + } + }); + } + }; + init(); +}; diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index e87a1a5c..e6c7591c 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -23,7 +23,7 @@ smapi.logParser = function (data, sectionUrl) { } // set local time started - if(data) + if (data) data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); // init app @@ -100,7 +100,7 @@ smapi.logParser = function (data, sectionUrl) { updateModFilters(); }, - filtersAllow: function(modId, level) { + filtersAllow: function (modId, level) { return this.showMods[modId] !== false && this.showLevels[level] !== false; }, @@ -121,16 +121,15 @@ smapi.logParser = function (data, sectionUrl) { var submit = $("#submit"); // instruction OS chooser - var chooseSystem = function() { + var chooseSystem = function () { systemInstructions.hide(); systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show(); - } + }; systemOptions.on("click", chooseSystem); chooseSystem(); // disable submit if it's empty - var toggleSubmit = function() - { + var toggleSubmit = function () { var hasText = !!input.val().trim(); submit.prop("disabled", !hasText); } @@ -139,18 +138,18 @@ smapi.logParser = function (data, sectionUrl) { // drag & drop file input.on({ - 'dragover dragenter': function(e) { + 'dragover dragenter': function (e) { e.preventDefault(); e.stopPropagation(); }, - 'drop': function(e) { + 'drop': function (e) { var dataTransfer = e.originalEvent.dataTransfer; if (dataTransfer && dataTransfer.files.length) { e.preventDefault(); e.stopPropagation(); var file = dataTransfer.files[0]; var reader = new FileReader(); - reader.onload = $.proxy(function(file, $input, event) { + reader.onload = $.proxy(function (file, $input, event) { $input.val(event.target.result); toggleSubmit(); }, this, file, $("#input")); diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json new file mode 100644 index 00000000..804eb53d --- /dev/null +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -0,0 +1,138 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://smapi.io/schemas/manifest.json", + "title": "SMAPI manifest", + "description": "Manifest file for a SMAPI mod or content pack", + "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest", + "type": "object", + "properties": { + "Name": { + "title": "Mod name", + "description": "The mod's display name. SMAPI uses this in player messages, logs, and errors.", + "type": "string", + "examples": [ "Lookup Anything" ] + }, + "Author": { + "title": "Mod author", + "description": "The name of the person who created the mod. Ideally this should include the username used to publish mods.", + "type": "string", + "examples": [ "Pathoschild" ] + }, + "Version": { + "title": "Mod version", + "description": "The mod's semantic version. Make sure you update this for each release! SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game).", + "$ref": "#/definitions/SemanticVersion" + }, + "Description": { + "title": "Mod description", + "description": "A short explanation of what your mod does (one or two sentences), shown in the SMAPI log.", + "type": "string", + "examples": [ "View metadata about anything by pressing a button." ] + }, + "UniqueID": { + "title": "Mod unique ID", + "description": "A unique identifier for your mod. The recommended format is \"Username.ModName\", with no spaces or special characters. SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game). When another mod needs to reference this mod, it uses the unique ID.", + "$ref": "#/definitions/ModID" + }, + "EntryDll": { + "title": "Mod entry DLL", + "description": "The DLL filename SMAPI should load for this mod. Mutually exclusive with ContentPackFor.", + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+\\.dll$", + "examples": "LookupAnything.dll", + "@errorMessages": { + "pattern": "Invalid value; must be a filename ending with .dll." + } + }, + "ContentPackFor": { + "title": "Content pack for", + "description": "Specifies the mod which can read this content pack.", + "type": "object", + "properties": { + "UniqueID": { + "title": "Required unique ID", + "description": "The unique ID of the mod which can read this content pack.", + "$ref": "#/definitions/ModID" + }, + "MinimumVersion": { + "title": "Required minimum version", + "description": "The minimum semantic version of the mod which can read this content pack, if applicable.", + "$ref": "#/definitions/SemanticVersion" + } + }, + + "required": [ "UniqueID" ] + }, + "MinimumApiVersion": { + "title": "Minimum API version", + "description": "The minimum SMAPI version needed to use this mod. If a player tries to use the mod with an older SMAPI version, they'll see a friendly message saying they need to update SMAPI. This also serves as a proxy for the minimum game version, since SMAPI itself enforces a minimum game version.", + "$ref": "#/definitions/SemanticVersion" + }, + "Dependencies": { + "title": "Mod dependencies", + "description": "Specifies other mods to load before this mod. If a dependency is required and a player tries to use the mod without the dependency installed, the mod won't be loaded and they'll see a friendly message saying they need to install those.", + "type": "array", + "items": { + "type": "object", + "properties": { + "UniqueID": { + "title": "Dependency unique ID", + "description": "The unique ID of the mod to load first.", + "$ref": "#/definitions/ModID" + }, + "MinimumVersion": { + "title": "Dependency minimum version", + "description": "The minimum semantic version of the mod to load first, if applicable.", + "$ref": "#/definitions/SemanticVersion" + }, + "IsRequired": { + "title": "Dependency is required", + "description": "Whether the dependency is required. Default true if not specified." + } + }, + "required": [ "UniqueID" ] + } + }, + "UpdateKeys": { + "title": "Mod update keys", + "description": "Specifies where SMAPI should check for mod updates, so it can alert the user with a link to your mod page. See https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info.", + "type": "array", + "items": { + "type": "string", + "pattern": "^(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_]+/[A-Za-z0-9_]+|ModDrop:\\d+)$", + "@errorMessages": { + "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." + } + } + } + }, + "definitions": { + "SemanticVersion": { + "type": "string", + "pattern": "^(?>(?0|[1-9]\\d*))\\.(?>(?0|[1-9]\\d*))(?>(?:\\.(?0|[1-9]\\d*))?)(?:-(?(?>[a-zA-Z0-9]+[\\-\\.]?)+))?$", // derived from SMAPI.Toolkit.SemanticVersion + "examples": [ "1.0.0", "1.0.1-beta.2" ], + "@errorMessages": { + "pattern": "Invalid semantic version; must be formatted like 1.2.0 or 1.2.0-prerelease.tags. See https://semver.org/ for more info." + } + }, + "ModID": { + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+$", // derived from SMAPI.Toolkit.Utilities.PathUtilities.IsSlug + "examples": [ "Pathoschild.LookupAnything" ] + } + }, + + "required": [ "Name", "Author", "Version", "Description", "UniqueID" ], + "oneOf": [ + { + "required": [ "EntryDll" ] + }, + { + "required": [ "ContentPackFor" ] + } + ], + "additionalProperties": false, + "@errorMessages": { + "oneOf": "Can't specify both EntryDll or ContentPackFor, they're mutually exclusive." + } +}