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)
+{
+
+ Oops, the server ran into trouble saving that file.
+ Error details: @Model.UploadError
+
+}
+else if (Model.ParseError != null)
+{
+
+ Oops, couldn't parse that JSON.
+ Share this link to let someone see this page: @(new Uri(new Uri(Model.SectionUrl), Model.PasteID))
+ (Or validate a new file.)
+
+ Error details: @Model.ParseError
+
+}
+else if (Model.PasteID != null)
+{
+
+ Share this link to let someone else see this page:@(new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}"))
+ (Or validate a new file.)
+