From 78d12da33e2d992c242a894fcbb6162c72623931 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Wed, 3 Jan 2018 14:13:04 +0000 Subject: [PATCH] Implement simplified RegisteredFunction.Invoke that uses JSON for marshalling --- .../src/RegisteredFunction.ts | 18 +- src/Microsoft.Blazor.Browser/Interop/Json.cs | 138 ++++ .../Interop/MiniJSON/MiniJSON.cs | 587 ++++++++++++++++++ .../Interop/RegisteredFunction.cs | 19 + .../Microsoft.Blazor.Browser.csproj | 2 +- 5 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.Blazor.Browser/Interop/Json.cs create mode 100644 src/Microsoft.Blazor.Browser/Interop/MiniJSON/MiniJSON.cs diff --git a/src/Microsoft.Blazor.Browser.JS/src/RegisteredFunction.ts b/src/Microsoft.Blazor.Browser.JS/src/RegisteredFunction.ts index d6a2442665..755aacb518 100644 --- a/src/Microsoft.Blazor.Browser.JS/src/RegisteredFunction.ts +++ b/src/Microsoft.Blazor.Browser.JS/src/RegisteredFunction.ts @@ -1,4 +1,7 @@ -const registeredFunctions = {}; +import { System_String } from './Platform/Platform'; +import { platform } from './Environment'; + +const registeredFunctions: { [identifier: string]: Function } = {}; // Code in Mono 'driver.c' looks for the registered functions here window['__blazorRegisteredFunctions'] = registeredFunctions; @@ -10,3 +13,16 @@ export function registerFunction(identifier: string, implementation: Function) { registeredFunctions[identifier] = implementation; } + +// Handle the JSON-marshalled RegisteredFunction.Invoke calls +registerFunction('__blazor_InvokeJson', (identifier: System_String, ...argsJson: System_String[]) => { + const identifierJsString = platform.toJavaScriptString(identifier); + if (!(registeredFunctions && registeredFunctions.hasOwnProperty(identifierJsString))) { + throw new Error(`Could not find registered function with name "${identifier}".`); + } + const funcInstance = registeredFunctions[identifierJsString]; + const args = argsJson.map(json => JSON.parse(platform.toJavaScriptString(json))); + const result = funcInstance.apply(null, args); + const resultJson = JSON.stringify(result); + return platform.toDotNetString(resultJson); +}); diff --git a/src/Microsoft.Blazor.Browser/Interop/Json.cs b/src/Microsoft.Blazor.Browser/Interop/Json.cs new file mode 100644 index 0000000000..96e67eb373 --- /dev/null +++ b/src/Microsoft.Blazor.Browser/Interop/Json.cs @@ -0,0 +1,138 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Microsoft.Blazor.Browser.Interop +{ + /// + /// Temporary internal JSON serialization library. + /// This will be removed when public JSON methods are added to Microsoft.Blazor + /// (which will be needed for HTTP support at least). + /// + internal static class Json + { + public static string Serialize(object value) + { + return MiniJSON.Json.Serialize(value); + } + + public static T Deserialize(string json) + { + var deserialized = MiniJSON.Json.Deserialize(json); + return (T)CoerceShallow(deserialized, typeof(T)); + } + + private static object CoerceShallow(object deserializedValue, Type typeOfT) + { + if (deserializedValue == null) + { + // Return default value for type + if (typeOfT.GetTypeInfo().IsValueType) + { + return Activator.CreateInstance(typeOfT); + } + else + { + return null; + } + } + else if (deserializedValue is int || deserializedValue is long) + { + var deserializedValueLong = (long)deserializedValue; + if (typeOfT == typeof(int)) { return (int)deserializedValueLong; } + if (typeOfT == typeof(int?)) { return new int?((int)deserializedValueLong); } + if (typeOfT == typeof(uint)) { return (uint)deserializedValueLong; } + if (typeOfT == typeof(long)) { return (long)deserializedValueLong; } + if (typeOfT == typeof(ulong)) { return (ulong)deserializedValueLong; } + if (typeOfT == typeof(short)) { return (short)deserializedValueLong; } + if (typeOfT == typeof(ushort)) { return (ushort)deserializedValueLong; } + if (typeOfT == typeof(float)) { return (float)deserializedValueLong; } + if (typeOfT == typeof(double)) { return (double)deserializedValueLong; } + + throw new ArgumentException($"Can't convert JSON value parsed as type {deserializedValue.GetType().FullName} to a value of type {typeOfT.FullName}"); + } + else if (deserializedValue is string s) + { + if (typeOfT == typeof(string)) + { + return deserializedValue; + } + + if (typeOfT == typeof(DateTime)) + { + return DateTime.Parse(s); + } + + if (typeOfT == typeof(DateTime?)) + { + return new DateTime?(DateTime.Parse(s)); + } + + throw new ArgumentException($"Can't convert JSON value parsed as type {deserializedValue.GetType().FullName} to a value of type {typeOfT.FullName}"); + } + else if (deserializedValue is bool) + { + if (typeOfT == typeof(bool)) + { + return deserializedValue; + } + + throw new ArgumentException($"Can't convert JSON value parsed as type {deserializedValue.GetType().FullName} to a value of type {typeOfT.FullName}"); + } + else if (deserializedValue is double) + { + var deserializedValueDouble = (double)deserializedValue; + if (typeOfT == typeof(float)) { return (float)deserializedValueDouble; } + if (typeOfT == typeof(double)) { return deserializedValueDouble; } + + throw new ArgumentException($"Can't convert JSON value parsed as type {deserializedValue.GetType().FullName} to a value of type {typeOfT.FullName}"); + } + else if (deserializedValue is List) + { + if (!typeOfT.IsArray) + { + return null; + //throw new ArgumentException($"Can't convert JSON array to type {typeOfT.FullName}, because that's not an array type."); + } + + var deserializedValueList = (List)deserializedValue; + var count = deserializedValueList.Count; + var elementType = typeOfT.GetElementType(); + var result = Array.CreateInstance(elementType, count); + for (var index = 0; index < count; index++) + { + var deserializedPropertyValue = deserializedValueList[index]; + var mappedPropertyValue = CoerceShallow(deserializedPropertyValue, elementType); + result.SetValue(mappedPropertyValue, index); + } + return result; + } + else if (deserializedValue is Dictionary) + { + var result = Activator.CreateInstance(typeOfT); + var deserializedPropertyDict = (Dictionary)deserializedValue; + foreach (var propInfo in typeOfT.GetRuntimeProperties()) + { + if (deserializedPropertyDict.TryGetValue(propInfo.Name, out var deserializedPropertyValue)) + { + var setMethod = propInfo.SetMethod; + if (!object.Equals(setMethod, null)) + { + var mappedPropertyValue = CoerceShallow(deserializedPropertyValue, propInfo.PropertyType); + setMethod.Invoke(result, new[] { mappedPropertyValue }); + } + } + } + + return result; + } + else + { + throw new ArgumentException($"Unexpected type received by CoerceShallow. Type was: { deserializedValue.GetType().FullName }"); + } + } + } +} diff --git a/src/Microsoft.Blazor.Browser/Interop/MiniJSON/MiniJSON.cs b/src/Microsoft.Blazor.Browser/Interop/MiniJSON/MiniJSON.cs new file mode 100644 index 0000000000..644f9c7d16 --- /dev/null +++ b/src/Microsoft.Blazor.Browser/Interop/MiniJSON/MiniJSON.cs @@ -0,0 +1,587 @@ +// From https://github.com/Jackyjjc/MiniJSON.cs @ 5665930fd7d10e41ea45152f5e1843c3fe8c2568 +// Changes: +// - Flag top-level type as 'internal', not 'public' +// - Add 'GetPropsAsDict' so that anonymously-typed objects are serialized as +// JSON objects recursively, instead of as theObject.ToString() + +/* + * Copyright (c) 2013 Calvin Rien + * + * Based on the JSON parser by Patrick van Bergen + * http://techblog.procurios.nl/k/618/news/view/14605/14863/How-do-I-write-my-own-parser-for-JSON.html + * + * Simplified it so that it doesn't throw exceptions + * and can be used in Unity iPhone with maximum code stripping. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; + +namespace MiniJSON { + // Example usage: + // + // using UnityEngine; + // using System.Collections; + // using System.Collections.Generic; + // using MiniJSON; + // + // public class MiniJSONTest : MonoBehaviour { + // void Start () { + // var jsonString = "{ \"array\": [1.44,2,3], " + + // "\"object\": {\"key1\":\"value1\", \"key2\":256}, " + + // "\"string\": \"The quick brown fox \\\"jumps\\\" over the lazy dog \", " + + // "\"unicode\": \"\\u3041 Men\u00fa sesi\u00f3n\", " + + // "\"int\": 65536, " + + // "\"float\": 3.1415926, " + + // "\"bool\": true, " + + // "\"null\": null }"; + // + // var dict = Json.Deserialize(jsonString) as Dictionary; + // + // Debug.Log("deserialized: " + dict.GetType()); + // Debug.Log("dict['array'][0]: " + ((List) dict["array"])[0]); + // Debug.Log("dict['string']: " + (string) dict["string"]); + // Debug.Log("dict['float']: " + (double) dict["float"]); // floats come out as doubles + // Debug.Log("dict['int']: " + (long) dict["int"]); // ints come out as longs + // Debug.Log("dict['unicode']: " + (string) dict["unicode"]); + // + // var str = Json.Serialize(dict); + // + // Debug.Log("serialized: " + str); + // } + // } + + /// + /// This class encodes and decodes JSON strings. + /// Spec. details, see http://www.json.org/ + /// + /// JSON uses Arrays and Objects. These correspond here to the datatypes IList and IDictionary. + /// All numbers are parsed to doubles. + /// + internal static class Json { + /// + /// Parses the string json into a value + /// + /// A JSON string. + /// An List<object>, a Dictionary<string, object>, a double, an integer,a string, null, true, or false + public static object Deserialize(string json) { + // save the string for debug information + if (json == null) { + return null; + } + + return Parser.Parse(json); + } + + sealed class Parser : IDisposable { + const string WORD_BREAK = "{}[],:\""; + + public static bool IsWordBreak(char c) { + return Char.IsWhiteSpace(c) || WORD_BREAK.IndexOf(c) != -1; + } + + const string HEX_DIGIT = "0123456789ABCDEFabcdef"; + + public static bool IsHexDigit(char c) { + return HEX_DIGIT.IndexOf(c) != -1; + } + + enum TOKEN { + NONE, + CURLY_OPEN, + CURLY_CLOSE, + SQUARED_OPEN, + SQUARED_CLOSE, + COLON, + COMMA, + STRING, + NUMBER, + TRUE, + FALSE, + NULL + }; + + StringReader json; + + Parser(string jsonString) { + json = new StringReader(jsonString); + } + + public static object Parse(string jsonString) { + using (var instance = new Parser(jsonString)) { + return instance.ParseValue(); + } + } + + public void Dispose() { + json.Dispose(); + json = null; + } + + Dictionary ParseObject() { + Dictionary table = new Dictionary(); + + // ditch opening brace + json.Read(); + + // { + while (true) { + switch (NextToken) { + case TOKEN.NONE: + return null; + case TOKEN.COMMA: + continue; + case TOKEN.CURLY_CLOSE: + return table; + case TOKEN.STRING: + // name + string name = ParseString(); + if (name == null) { + return null; + } + + // : + if (NextToken != TOKEN.COLON) { + return null; + } + // ditch the colon + json.Read(); + + // value + TOKEN valueToken = NextToken; + object value = ParseByToken(valueToken); + if(value==null && valueToken!=TOKEN.NULL) + return null; + table[name] = value; + break; + default: + return null; + } + } + } + + List ParseArray() { + List array = new List(); + + // ditch opening bracket + json.Read(); + + // [ + var parsing = true; + while (parsing) { + TOKEN nextToken = NextToken; + + switch (nextToken) { + case TOKEN.NONE: + return null; + case TOKEN.COMMA: + continue; + case TOKEN.SQUARED_CLOSE: + parsing = false; + break; + default: + object value = ParseByToken(nextToken); + if(value==null && nextToken!=TOKEN.NULL) + return null; + array.Add(value); + break; + } + } + + return array; + } + + object ParseValue() { + TOKEN nextToken = NextToken; + return ParseByToken(nextToken); + } + + object ParseByToken(TOKEN token) { + switch (token) { + case TOKEN.STRING: + return ParseString(); + case TOKEN.NUMBER: + return ParseNumber(); + case TOKEN.CURLY_OPEN: + return ParseObject(); + case TOKEN.SQUARED_OPEN: + return ParseArray(); + case TOKEN.TRUE: + return true; + case TOKEN.FALSE: + return false; + case TOKEN.NULL: + return null; + default: + return null; + } + } + + string ParseString() { + StringBuilder s = new StringBuilder(); + char c; + + // ditch opening quote + json.Read(); + + bool parsing = true; + while (parsing) { + + if (json.Peek() == -1) { + parsing = false; + break; + } + + c = NextChar; + switch (c) { + case '"': + parsing = false; + break; + case '\\': + if (json.Peek() == -1) { + parsing = false; + break; + } + + c = NextChar; + switch (c) { + case '"': + case '\\': + case '/': + s.Append(c); + break; + case 'b': + s.Append('\b'); + break; + case 'f': + s.Append('\f'); + break; + case 'n': + s.Append('\n'); + break; + case 'r': + s.Append('\r'); + break; + case 't': + s.Append('\t'); + break; + case 'u': + var hex = new char[4]; + + for (int i=0; i< 4; i++) { + hex[i] = NextChar; + if (!IsHexDigit(hex[i])) + return null; + } + + s.Append((char) Convert.ToInt32(new string(hex), 16)); + break; + } + break; + default: + s.Append(c); + break; + } + } + + return s.ToString(); + } + + object ParseNumber() { + string number = NextWord; + + if (number.IndexOf('.') == -1 && number.IndexOf('E') == -1 && number.IndexOf('e') == -1) { + long parsedInt; + Int64.TryParse(number, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out parsedInt); + return parsedInt; + } + + double parsedDouble; + Double.TryParse(number, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out parsedDouble); + return parsedDouble; + } + + void EatWhitespace() { + while (Char.IsWhiteSpace(PeekChar)) { + json.Read(); + + if (json.Peek() == -1) { + break; + } + } + } + + char PeekChar { + get { + return Convert.ToChar(json.Peek()); + } + } + + char NextChar { + get { + return Convert.ToChar(json.Read()); + } + } + + string NextWord { + get { + StringBuilder word = new StringBuilder(); + + while (!IsWordBreak(PeekChar)) { + word.Append(NextChar); + + if (json.Peek() == -1) { + break; + } + } + + return word.ToString(); + } + } + + TOKEN NextToken { + get { + EatWhitespace(); + + if (json.Peek() == -1) { + return TOKEN.NONE; + } + + switch (PeekChar) { + case '{': + return TOKEN.CURLY_OPEN; + case '}': + json.Read(); + return TOKEN.CURLY_CLOSE; + case '[': + return TOKEN.SQUARED_OPEN; + case ']': + json.Read(); + return TOKEN.SQUARED_CLOSE; + case ',': + json.Read(); + return TOKEN.COMMA; + case '"': + return TOKEN.STRING; + case ':': + return TOKEN.COLON; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '-': + return TOKEN.NUMBER; + } + + switch (NextWord) { + case "false": + return TOKEN.FALSE; + case "true": + return TOKEN.TRUE; + case "null": + return TOKEN.NULL; + } + + return TOKEN.NONE; + } + } + } + + /// + /// Converts a IDictionary / IList object or a simple type (string, int, etc.) into a JSON string + /// + /// A Dictionary<string, object> / List<object> + /// A JSON encoded string, or null if object 'json' is not serializable + public static string Serialize(object obj) { + return Serializer.Serialize(obj); + } + + sealed class Serializer { + StringBuilder builder; + + Serializer() { + builder = new StringBuilder(); + } + + public static string Serialize(object obj) { + var instance = new Serializer(); + + instance.SerializeValue(obj); + + return instance.builder.ToString(); + } + + void SerializeValue(object value) { + IList asList; + IDictionary asDict; + string asStr; + + if (value == null) { + builder.Append("null"); + } else if ((asStr = value as string) != null) { + SerializeString(asStr); + } else if (value is bool) { + builder.Append((bool) value ? "true" : "false"); + } else if ((asList = value as IList) != null) { + SerializeArray(asList); + } else if ((asDict = value as IDictionary) != null) { + SerializeObject(asDict); + } else if (value is char) { + SerializeString(new string((char) value, 1)); + } else { + SerializeOther(value); + } + } + + void SerializeObject(IDictionary obj) { + bool first = true; + + builder.Append('{'); + + foreach (object e in obj.Keys) { + if (!first) { + builder.Append(','); + } + + SerializeString(e.ToString()); + builder.Append(':'); + + SerializeValue(obj[e]); + + first = false; + } + + builder.Append('}'); + } + + void SerializeArray(IList anArray) { + builder.Append('['); + + bool first = true; + + for (int i=0; i= 32) && (codepoint <= 126)) { + builder.Append(c); + } else { + builder.Append("\\u"); + builder.Append(codepoint.ToString("x4")); + } + break; + } + } + + builder.Append('\"'); + } + + void SerializeOther(object value) { + // NOTE: decimals lose precision during serialization. + // They always have, I'm just letting you know. + // Previously floats and doubles lost precision too. + if (value is float) { + builder.Append(((float) value).ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + } else if (value is int + || value is uint + || value is long + || value is sbyte + || value is byte + || value is short + || value is ushort + || value is ulong) { + builder.Append(value); + } else if (value is double + || value is decimal) { + builder.Append(Convert.ToDouble(value).ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + } else { + SerializeObject(GetPropsAsDict(value)); + } + } + + private Dictionary GetPropsAsDict(object value) + { + var propsDict = new Dictionary(); + var propInfos = value.GetType().GetRuntimeProperties(); + foreach (var pi in propInfos) + { + var getMethod = pi.GetMethod; + if (getMethod != null) + { + propsDict[pi.Name] = getMethod.Invoke(value, null); + } + } + + return propsDict; + } + } + } +} diff --git a/src/Microsoft.Blazor.Browser/Interop/RegisteredFunction.cs b/src/Microsoft.Blazor.Browser/Interop/RegisteredFunction.cs index e9c624f7b2..7d4ac60b23 100644 --- a/src/Microsoft.Blazor.Browser/Interop/RegisteredFunction.cs +++ b/src/Microsoft.Blazor.Browser/Interop/RegisteredFunction.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Linq; using WebAssembly; namespace Microsoft.Blazor.Browser.Interop @@ -10,6 +11,24 @@ namespace Microsoft.Blazor.Browser.Interop /// public static class RegisteredFunction { + /// + /// Invokes the JavaScript function registered with the specified identifier. + /// Arguments and return values are marshalled via JSON serialization. + /// + /// The .NET type corresponding to the function's return value type. This type must be JSON deserializable. + /// The identifier used when registering the target function. + /// The arguments to pass, each of which must be JSON serializable. + /// The result of the function invocation. + public static TRes Invoke(string identifier, params object[] args) + { + // This is a low-perf convenience method that bypasses the need to deal with + // .NET memory and data structures on the JS side + var argsJson = args.Select(Json.Serialize); + var resultJson = InvokeUnmarshalled("__blazor_InvokeJson", + argsJson.Prepend(identifier).ToArray()); + return Json.Deserialize(resultJson); + } + /// /// Invokes the JavaScript function registered with the specified identifier. /// diff --git a/src/Microsoft.Blazor.Browser/Microsoft.Blazor.Browser.csproj b/src/Microsoft.Blazor.Browser/Microsoft.Blazor.Browser.csproj index 5766db614c..9f5c4f4abb 100644 --- a/src/Microsoft.Blazor.Browser/Microsoft.Blazor.Browser.csproj +++ b/src/Microsoft.Blazor.Browser/Microsoft.Blazor.Browser.csproj @@ -1,7 +1,7 @@ - netcoreapp2.0 + netstandard2.0