Merge branch 'json-validator' into develop

This commit is contained in:
Jesse Plamondon-Willard 2019-09-14 19:01:53 -04:00
commit 0c5fa11809
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
22 changed files with 1144 additions and 94 deletions

View File

@ -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

View File

@ -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
{
/// <summary>Provides a web UI for validating JSON schemas.</summary>
internal class JsonValidatorController : Controller
{
/*********
** Fields
*********/
/// <summary>The site config settings.</summary>
private readonly SiteConfig Config;
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
/// <summary>The underlying text compression helper.</summary>
private readonly IGzipHelper GzipHelper;
/// <summary>The section URL for the schema validator.</summary>
private string SectionUrl => this.Config.JsonValidatorUrl;
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
{
["none"] = "None",
["manifest"] = "Manifest"
};
/// <summary>The schema ID to use if none was specified.</summary>
private string DefaultSchemaID = "manifest";
/*********
** Public methods
*********/
/***
** Constructor
***/
/// <summary>Construct an instance.</summary>
/// <param name="siteConfig">The context config settings.</param>
/// <param name="pastebin">The Pastebin API client.</param>
/// <param name="gzipHelper">The underlying text compression helper.</param>
public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
{
this.Config = siteConfig.Value;
this.Pastebin = pastebin;
this.GzipHelper = gzipHelper;
}
/***
** Web UI
***/
/// <summary>Render the schema validator UI.</summary>
/// <param name="schemaName">The schema name with which to validate the JSON.</param>
/// <param name="id">The paste ID.</param>
[HttpGet]
[Route("json")]
[Route("json/{schemaName}")]
[Route("json/{schemaName}/{id}")]
public async Task<ViewResult> 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<string>(schema, "@documentationUrl");
// validate JSON
parsed.IsValid(schema, out IList<ValidationError> rawErrors);
var errors = rawErrors
.Select(error => new JsonValidatorErrorModel(error.LineNumber, error.Path, this.GetFlattenedError(error)))
.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", 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
*********/
/// <summary>Fetch raw text from Pastebin.</summary>
/// <param name="id">The Pastebin paste ID.</param>
private async Task<PasteInfo> GetAsync(string id)
{
PasteInfo response = await this.Pastebin.GetAsync(id);
response.Content = this.GzipHelper.DecompressString(response.Content);
return response;
}
/// <summary>Get a flattened, human-readable message representing a schema validation error.</summary>
/// <param name="error">The error to represent.</param>
/// <param name="indent">The indentation level to apply for inner errors.</param>
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<string>)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;
}
/// <summary>Get a normalised schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
/// <param name="schemaName">The raw schema name to normalise.</param>
private string NormaliseSchemaName(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)
{
// 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;
}
/// <summary>Get an override error from the JSON schema, if any.</summary>
/// <param name="schema">The schema or subschema that raised the error.</param>
/// <param name="errorType">The error type.</param>
private string GetOverrideError(JSchema schema, ErrorType errorType)
{
// get override errors
IDictionary<string, string> errors = this.GetExtensionField<Dictionary<string, string>>(schema, "@errorMessages");
if (errors == null)
return null;
errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase);
// get matching error
return errors.TryGetValue(errorType.ToString(), out string errorPhrase)
? errorPhrase
: null;
}
/// <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 (var pair in schema.ExtensionData)
{
if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase))
return pair.Value.ToObject<T>();
}
}
return default;
}
}
}

View File

@ -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
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
/// <summary>The first bytes in a valid zip file.</summary>
/// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
private const uint GzipLeadBytes = 0x8b1f;
/// <summary>The underlying text compression helper.</summary>
private readonly IGzipHelper GzipHelper;
/*********
@ -41,10 +38,12 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="siteConfig">The context config settings.</param>
/// <param name="pastebin">The Pastebin API client.</param>
public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin)
/// <param name="gzipHelper">The underlying text compression helper.</param>
public LogParserController(IOptions<SiteConfig> 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<PasteInfo> 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;
}
/// <summary>Compress a string.</summary>
/// <param name="text">The text to compress.</param>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
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);
}
/// <summary>Decompress a string.</summary>
/// <param name="rawText">The compressed text.</param>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
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);
}
}
}
}

View File

@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
Task<PasteInfo> GetAsync(string id);
/// <summary>Save a paste to Pastebin.</summary>
/// <param name="name">The paste name.</param>
/// <param name="content">The paste content.</param>
Task<SavePasteResult> PostAsync(string content);
Task<SavePasteResult> PostAsync(string name, string content);
}
}

View File

@ -67,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
}
/// <summary>Save a paste to Pastebin.</summary>
/// <param name="name">The paste name.</param>
/// <param name="content">The paste content.</param>
public async Task<SavePasteResult> PostAsync(string content)
public async Task<SavePasteResult> 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
}))

View File

@ -0,0 +1,89 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace StardewModdingAPI.Web.Framework.Compression
{
/// <summary>Handles GZip compression logic.</summary>
internal class GzipHelper : IGzipHelper
{
/*********
** Fields
*********/
/// <summary>The first bytes in a valid zip file.</summary>
/// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks>
private const uint GzipLeadBytes = 0x8b1f;
/*********
** Public methods
*********/
/// <summary>Compress a string.</summary>
/// <param name="text">The text to compress.</param>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
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);
}
/// <summary>Decompress a string.</summary>
/// <param name="rawText">The compressed text.</param>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
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);
}
}
}
}

View File

@ -0,0 +1,17 @@
namespace StardewModdingAPI.Web.Framework.Compression
{
/// <summary>Handles GZip compression logic.</summary>
internal interface IGzipHelper
{
/*********
** Methods
*********/
/// <summary>Compress a string.</summary>
/// <param name="text">The text to compress.</param>
string CompressString(string text);
/// <summary>Decompress a string.</summary>
/// <param name="rawText">The compressed text.</param>
string DecompressString(string rawText);
}
}

View File

@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The root URL for the log parser.</summary>
public string LogParserUrl { get; set; }
/// <summary>The root URL for the JSON validator.</summary>
public string JsonValidatorUrl { get; set; }
/// <summary>The root URL for the mod list.</summary>
public string ModListUrl { get; set; }

View File

@ -26,6 +26,7 @@
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.2.0" />
<PackageReference Include="MongoDB.Driver" Version="2.8.1" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
<PackageReference Include="Pathoschild.FluentNexus" Version="0.7.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
</ItemGroup>

View File

@ -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<IGzipHelper>(new GzipHelper());
}
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
@ -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")
));

View File

@ -0,0 +1,36 @@
namespace StardewModdingAPI.Web.ViewModels.JsonValidator
{
/// <summary>The view model for a JSON validator error.</summary>
public class JsonValidatorErrorModel
{
/*********
** Accessors
*********/
/// <summary>The line number on which the error occurred.</summary>
public int Line { get; set; }
/// <summary>The field path in the JSON file where the error occurred.</summary>
public string Path { get; set; }
/// <summary>A human-readable description of the error.</summary>
public string Message { get; set; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public JsonValidatorErrorModel() { }
/// <summary>Construct an instance.</summary>
/// <param name="line">The line number on which the error occurred.</param>
/// <param name="path">The field path in the JSON file where the error occurred.</param>
/// <param name="message">A human-readable description of the error.</param>
public JsonValidatorErrorModel(int line, string path, string message)
{
this.Line = line;
this.Path = path;
this.Message = message;
}
}
}

View File

@ -0,0 +1,95 @@
using System.Collections.Generic;
using System.Linq;
namespace StardewModdingAPI.Web.ViewModels.JsonValidator
{
/// <summary>The view model for the JSON validator page.</summary>
public class JsonValidatorModel
{
/*********
** Accessors
*********/
/// <summary>The root URL for the log parser controller.</summary>
public string SectionUrl { get; set; }
/// <summary>The paste ID.</summary>
public string PasteID { get; set; }
/// <summary>The schema name with which the JSON was validated.</summary>
public string SchemaName { get; set; }
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
public readonly IDictionary<string, string> SchemaFormats;
/// <summary>The validated content.</summary>
public string Content { get; set; }
/// <summary>The schema validation errors, if any.</summary>
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
/// <summary>An error which occurred while uploading the JSON to Pastebin.</summary>
public string UploadError { get; set; }
/// <summary>An error which occurred while parsing the JSON.</summary>
public string ParseError { get; set; }
/// <summary>A web URL to the user-facing format documentation.</summary>
public string FormatUrl { get; set; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public JsonValidatorModel() { }
/// <summary>Construct an instance.</summary>
/// <param name="sectionUrl">The root URL for the log parser controller.</param>
/// <param name="pasteID">The paste ID.</param>
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
{
this.SectionUrl = sectionUrl;
this.PasteID = pasteID;
this.SchemaName = schemaName;
this.SchemaFormats = schemaFormats;
}
/// <summary>Set the validated content.</summary>
/// <param name="content">The validated content.</param>
public JsonValidatorModel SetContent(string content)
{
this.Content = content;
return this;
}
/// <summary>Set the error which occurred while uploading the log to Pastebin.</summary>
/// <param name="error">The error message.</param>
public JsonValidatorModel SetUploadError(string error)
{
this.UploadError = error;
return this;
}
/// <summary>Set the error which occurred while parsing the JSON.</summary>
/// <param name="error">The error message.</param>
public JsonValidatorModel SetParseError(string error)
{
this.ParseError = error;
return this;
}
/// <summary>Add validation errors to the response.</summary>
/// <param name="errors">The schema validation errors.</param>
public JsonValidatorModel AddErrors(params JsonValidatorErrorModel[] errors)
{
this.Errors = this.Errors.Concat(errors).ToArray();
return this;
}
}
}

View File

@ -0,0 +1,15 @@
namespace StardewModdingAPI.Web.ViewModels.JsonValidator
{
/// <summary>The view model for a JSON validation request.</summary>
public class JsonValidatorRequestModel
{
/*********
** Accessors
*********/
/// <summary>The schema name with which to validate the JSON.</summary>
public string SchemaName { get; set; }
/// <summary>The raw content to validate.</summary>
public string Content { get; set; }
}
}

View File

@ -0,0 +1,131 @@
@using StardewModdingAPI.Web.ViewModels.JsonValidator
@model JsonValidatorModel
@{
ViewData["Title"] = "JSON validator";
}
@section Head {
@if (Model.PasteID != null)
{
<meta name="robots" content="noindex" />
}
<link rel="stylesheet" href="~/Content/css/json-validator.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
<script src="~/Content/js/json-validator.js"></script>
<script>
$(function () {
smapi.jsonValidator(@Json.Serialize(Model.SectionUrl), @Json.Serialize(Model.PasteID));
});
</script>
}
@* upload result banner *@
@if (Model.UploadError != null)
{
<div class="banner error">
<strong>Oops, the server ran into trouble saving that file.</strong><br />
<small>Error details: @Model.UploadError</small>
</div>
}
else if (Model.ParseError != null)
{
<div class="banner error">
<strong>Oops, couldn't parse that JSON.</strong><br />
Share this link to let someone see this page: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
(Or <a href="@Model.SectionUrl">validate a new file</a>.)<br />
<br />
<small v-pre>Error details: @Model.ParseError</small>
</div>
}
else if (Model.PasteID != null)
{
<div class="banner success">
<strong>Share this link to let someone else see this page:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}"))</code><br />
(Or <a href="@Model.SectionUrl">validate a new file</a>.)
</div>
}
@* upload new file *@
@if (Model.Content == null)
{
<h2>Upload a JSON file</h2>
<form action="@Model.SectionUrl" method="post">
<ol>
<li>
Choose the JSON format:<br />
<select id="format" name="SchemaName">
@foreach (var pair in Model.SchemaFormats)
{
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
}
</select>
</li>
<li>
Drag the file onto this textbox (or paste the text in):<br />
<textarea id="input" name="Content" placeholder="paste file here"></textarea>
</li>
<li>
Click this button:<br />
<input type="submit" id="submit" value="save file" />
</li>
</ol>
</form>
}
@* validation results *@
@if (Model.Content != null)
{
<div id="output">
@if (Model.UploadError == null)
{
<div>
Change JSON format:
<select id="format" name="format">
@foreach (var pair in Model.SchemaFormats)
{
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
}
</select>
</div>
<h2>Validation errors</h2>
@if (Model.FormatUrl != null)
{
<p>See <a href="@Model.FormatUrl">format documentation</a>.</p>
}
@if (Model.Errors.Any())
{
<table id="metadata" class="table">
<tr>
<th>Line</th>
<th>Field</th>
<th>Error</th>
</tr>
@foreach (JsonValidatorErrorModel error in Model.Errors)
{
<tr>
<td><a href="#L@(error.Line)">@error.Line</a></td>
<td>@error.Path</td>
<td>@error.Message</td>
</tr>
}
</table>
}
else
{
<p>No errors found.</p>
}
}
<h2>Raw content</h2>
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>
</div>
}

View File

@ -16,9 +16,14 @@
<h4>SMAPI</h4>
<ul>
<li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li>
<li><a href="https://stardewvalleywiki.com/Modding:Index">Modding docs</a></li>
</ul>
<h4>Tools</h4>
<ul>
<li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li>
<li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li>
<li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li>
<li><a href="@SiteConfig.Value.JsonValidatorUrl">JSON validator</a></li>
</ul>
</div>
<div id="content-column">

View File

@ -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
},

View File

@ -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
},

View File

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

View File

@ -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;

View File

@ -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<int>} 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();
};

View File

@ -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"));

View File

@ -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": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[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."
}
}