From 747ca019c9b521897740489013a86c7073052126 Mon Sep 17 00:00:00 2001 From: Stephen Hodgson Date: Thu, 6 Jun 2024 03:34:27 -0400 Subject: [PATCH] com.utilities.rest 3.0.0 (#77) - overhauled Server Sent Event callbacks - properly handles all event types and fields - breaking change: data payloads now come nested in json object - Rest.Response.ctr has new overload which takes RestParameters - updated com.utilities.async -> 2.1.6 --- README.md | 15 ++++ .../Documentation~/README.md | 15 ++++ .../Runtime/DownloadHandlerCallback.cs | 4 +- .../com.utilities.rest/Runtime/Response.cs | 53 ++++++------ .../com.utilities.rest/Runtime/Rest.cs | 84 ++++++++++++------- .../Runtime/RestParameters.cs | 2 + .../Packages/com.utilities.rest/package.json | 4 +- Utilities.Rest/Packages/manifest.json | 2 +- 8 files changed, 119 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 2318841..9643ce2 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,21 @@ response.Validate(debug: true); #### Server Sent Events +> [!WARNING] +> Breaking change. `eventData` payloads are now json objects where the type is the key and field data is value. +> For existing data callbacks, they are now nested: `{"data":"{}"}` + +Handles [server sent event](https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events) messages. + +`eventData` json Schema: + +```json +{ + "type":"value", + "data":"{}" // nullable +} +``` + ```csharp var jsonData = "{\"data\":\"content\"}"; var response = await Rest.PostAsync("www.your.api/endpoint", jsonData, eventData => { diff --git a/Utilities.Rest/Packages/com.utilities.rest/Documentation~/README.md b/Utilities.Rest/Packages/com.utilities.rest/Documentation~/README.md index 6c24e31..5a9e264 100644 --- a/Utilities.Rest/Packages/com.utilities.rest/Documentation~/README.md +++ b/Utilities.Rest/Packages/com.utilities.rest/Documentation~/README.md @@ -115,6 +115,21 @@ response.Validate(debug: true); #### Server Sent Events +> [!WARNING] +> Breaking change. `eventData` payloads are now json objects where the type is the key and field data is value. +> For existing data callbacks, they are now nested: `{"data":"{}"}` + +Handles [server sent event](https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events) messages. + +`eventData` json Schema: + +```json +{ + "type":"value", + "data":"{}" // nullable +} +``` + ```csharp var jsonData = "{\"data\":\"content\"}"; var response = await Rest.PostAsync("www.your.api/endpoint", jsonData, eventData => { diff --git a/Utilities.Rest/Packages/com.utilities.rest/Runtime/DownloadHandlerCallback.cs b/Utilities.Rest/Packages/com.utilities.rest/Runtime/DownloadHandlerCallback.cs index bc5ff6c..b85a3c2 100644 --- a/Utilities.Rest/Packages/com.utilities.rest/Runtime/DownloadHandlerCallback.cs +++ b/Utilities.Rest/Packages/com.utilities.rest/Runtime/DownloadHandlerCallback.cs @@ -48,7 +48,7 @@ protected override bool ReceiveData(byte[] unprocessedData, int dataLength) var buffer = new byte[bytesToRead]; var bytesRead = stream.Read(buffer, 0, (int)bytesToRead); streamPosition += bytesRead; - OnDataReceived?.Invoke(new Response(webRequest.url, webRequest.method, null, true, null, buffer, webRequest.responseCode, webRequest.GetResponseHeaders())); + OnDataReceived?.Invoke(new Response(webRequest.url, webRequest.method, null, true, null, buffer, webRequest.responseCode, webRequest.GetResponseHeaders(), null, null)); } } catch (Exception e) @@ -69,7 +69,7 @@ protected override void CompleteContent() var buffer = new byte[StreamOffset]; var bytesRead = stream.Read(buffer); streamPosition += bytesRead; - OnDataReceived?.Invoke(new Response(webRequest.url, webRequest.method, null, true, null, buffer, webRequest.responseCode, webRequest.GetResponseHeaders())); + OnDataReceived?.Invoke(new Response(webRequest.url, webRequest.method, null, true, null, buffer, webRequest.responseCode, webRequest.GetResponseHeaders(), null, null)); } } catch (Exception e) diff --git a/Utilities.Rest/Packages/com.utilities.rest/Runtime/Response.cs b/Utilities.Rest/Packages/com.utilities.rest/Runtime/Response.cs index 18c2bb6..b6703a7 100644 --- a/Utilities.Rest/Packages/com.utilities.rest/Runtime/Response.cs +++ b/Utilities.Rest/Packages/com.utilities.rest/Runtime/Response.cs @@ -58,6 +58,11 @@ public class Response /// public string Error { get; } + /// + /// Request parameters. + /// + public RestParameters Parameters { get; } + /// /// Constructor. /// @@ -69,8 +74,9 @@ public class Response /// Response data from the resource. /// Response code from the resource. /// Response headers from the resource. + /// The parameters of the request. /// Optional, error message from the resource. - public Response(string request, string method, string requestBody, bool successful, string body, byte[] data, long responseCode, IReadOnlyDictionary headers, string error = null) + public Response(string request, string method, string requestBody, bool successful, string body, byte[] data, long responseCode, IReadOnlyDictionary headers, RestParameters parameters, string error = null) { Request = request; RequestBody = requestBody; @@ -81,23 +87,14 @@ public Response(string request, string method, string requestBody, bool successf Code = responseCode; Headers = headers; Error = error; + Parameters = parameters; } - /// - /// Constructor. - /// - /// The request that prompted the response. - /// The request method that prompted the response. - /// Was the REST call successful? - /// Response body from the resource. - /// Response data from the resource. - /// Response code from the resource. - /// Response headers from the resource. - /// Optional, error message from the resource. - [Obsolete("Use new .ctr with requestBody")] - public Response(string request, string method, bool successful, string body, byte[] data, long responseCode, IReadOnlyDictionary headers, string error = null) + [Obsolete("Use new .ctr with parameters")] + public Response(string request, string method, string requestBody, bool successful, string body, byte[] data, long responseCode, IReadOnlyDictionary headers, string error = null) { Request = request; + RequestBody = requestBody; Method = method; Successful = successful; Body = body; @@ -159,24 +156,30 @@ public string ToString(string methodName) if (!string.IsNullOrWhiteSpace(Body)) { - var parts = Body.Split("data: "); - - if (parts.Length > 0) + if (Parameters.ServerSentEvents.Count > 0) { debugMessageObject["response"]["body"] = new JArray(); - foreach (var part in parts) + foreach (var (type, value, data) in Parameters.ServerSentEvents) { - if (string.IsNullOrWhiteSpace(part) || part.Contains("[DONE]\n\n")) { continue; } - - try + var eventObject = new JObject { - ((JArray)debugMessageObject["response"]["body"]).Add(JToken.Parse(part)); - } - catch + [type] = value + }; + + if (!string.IsNullOrWhiteSpace(data)) { - ((JArray)debugMessageObject["response"]["body"]).Add(new JValue(part)); + try + { + eventObject[nameof(data)] = JToken.Parse(data); + } + catch + { + eventObject[nameof(data)] = data; + } } + + ((JArray)debugMessageObject["response"]["body"]).Add(eventObject); } } else diff --git a/Utilities.Rest/Packages/com.utilities.rest/Runtime/Rest.cs b/Utilities.Rest/Packages/com.utilities.rest/Runtime/Rest.cs index 7ec8f9b..8e9707e 100644 --- a/Utilities.Rest/Packages/com.utilities.rest/Runtime/Rest.cs +++ b/Utilities.Rest/Packages/com.utilities.rest/Runtime/Rest.cs @@ -4,9 +4,9 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Runtime.CompilerServices; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using UnityEngine; @@ -25,12 +25,13 @@ public static class Rest { internal const string FileUriPrefix = "file://"; private const string kHttpVerbPATCH = "PATCH"; - private const string eventDelimiter = "data: "; - private const string stopEventDelimiter = "[DONE]"; private const string content_type = "Content-Type"; private const string content_length = "Content-Length"; private const string application_json = "application/json"; private const string application_octet_stream = "application/octet-stream"; + private const string ssePattern = @"(?:(?:(?[^:\n]*):)(?(?:(?!\n\n|\ndata:).)*)(?:\ndata:(?(?:(?!\n\n).)*))?\n\n)"; + + private static readonly Regex sseRegex = new(ssePattern); #region Authentication @@ -1203,7 +1204,7 @@ async void CallbackThread() } catch (Exception e) { - return new Response(webRequest.url, webRequest.method, requestBody, false, $"{nameof(Rest)}.{nameof(SendAsync)}::{nameof(UnityWebRequest.SendWebRequest)} Failed!", null, -1, null, e.ToString()); + return new Response(webRequest.url, webRequest.method, requestBody, false, $"{nameof(Rest)}.{nameof(SendAsync)}::{nameof(UnityWebRequest.SendWebRequest)} Failed!", null, -1, null, parameters, e.ToString()); } parameters?.Progress?.Report(new Progress(webRequest.downloadedBytes, webRequest.downloadedBytes, 100f, 0, Progress.DataUnit.b)); @@ -1216,13 +1217,13 @@ UnityWebRequest.Result.ConnectionError or { return webRequest.downloadHandler switch { - DownloadHandlerFile => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), - DownloadHandlerTexture => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), - DownloadHandlerAudioClip => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), - DownloadHandlerAssetBundle => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), - DownloadHandlerBuffer bufferDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, false, bufferDownloadHandler.text, bufferDownloadHandler.data, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), - DownloadHandlerScript scriptDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, false, scriptDownloadHandler.text, scriptDownloadHandler.data, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), - _ => new Response(webRequest.url, webRequest.method, requestBody, false, webRequest.responseCode == 401 ? "Invalid Credentials" : webRequest.downloadHandler?.text, webRequest.downloadHandler?.data, webRequest.responseCode, responseHeaders, $"{webRequest.error}\n{webRequest.downloadHandler?.error}") + DownloadHandlerFile => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), + DownloadHandlerTexture => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), + DownloadHandlerAudioClip => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), + DownloadHandlerAssetBundle => new Response(webRequest.url, webRequest.method, requestBody, false, null, null, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), + DownloadHandlerBuffer bufferDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, false, bufferDownloadHandler.text, bufferDownloadHandler.data, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), + DownloadHandlerScript scriptDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, false, scriptDownloadHandler.text, scriptDownloadHandler.data, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}"), + _ => new Response(webRequest.url, webRequest.method, requestBody, false, webRequest.responseCode == 401 ? "Invalid Credentials" : webRequest.downloadHandler?.text, webRequest.downloadHandler?.data, webRequest.responseCode, responseHeaders, parameters, $"{webRequest.error}\n{webRequest.downloadHandler?.error}") }; } @@ -1233,35 +1234,58 @@ UnityWebRequest.Result.ConnectionError or return webRequest.downloadHandler switch { - DownloadHandlerFile => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders), - DownloadHandlerTexture => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders), - DownloadHandlerAudioClip => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders), - DownloadHandlerAssetBundle => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders), - DownloadHandlerBuffer bufferDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, true, bufferDownloadHandler.text, bufferDownloadHandler.data, webRequest.responseCode, responseHeaders), - DownloadHandlerScript scriptDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, true, scriptDownloadHandler.text, scriptDownloadHandler.data, webRequest.responseCode, responseHeaders), - _ => new Response(webRequest.url, webRequest.method, requestBody, true, webRequest.downloadHandler?.text, webRequest.downloadHandler?.data, webRequest.responseCode, responseHeaders) + DownloadHandlerFile => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders, parameters), + DownloadHandlerTexture => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders, parameters), + DownloadHandlerAudioClip => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders, parameters), + DownloadHandlerAssetBundle => new Response(webRequest.url, webRequest.method, requestBody, true, null, null, webRequest.responseCode, responseHeaders, parameters), + DownloadHandlerBuffer bufferDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, true, bufferDownloadHandler.text, bufferDownloadHandler.data, webRequest.responseCode, responseHeaders, parameters), + DownloadHandlerScript scriptDownloadHandler => new Response(webRequest.url, webRequest.method, requestBody, true, scriptDownloadHandler.text, scriptDownloadHandler.data, webRequest.responseCode, responseHeaders, parameters), + _ => new Response(webRequest.url, webRequest.method, requestBody, true, webRequest.downloadHandler?.text, webRequest.downloadHandler?.data, webRequest.responseCode, responseHeaders, parameters) }; void SendServerEventCallback(bool isEnd) { + var allEventMessages = webRequest.downloadHandler?.text; + if (string.IsNullOrWhiteSpace(allEventMessages)) { return; } + + var matches = sseRegex.Matches(allEventMessages!); + var stride = isEnd ? 0 : 1; parameters ??= new RestParameters(); - var lines = webRequest.downloadHandler?.text - .Split(eventDelimiter, StringSplitOptions.RemoveEmptyEntries) - .ToArray(); - if (lines is { Length: > 1 }) + for (var i = parameters.ServerSentEventCount; i < matches.Count - stride; i++) { - var stride = isEnd ? 0 : 1; - for (var i = parameters.ServerSentEventCount; i < lines.Length - stride; i++) + string type; + string value; + string data; + + var match = matches[i]; + + const string comment = nameof(comment); + type = match.Groups[nameof(type)].Value.Trim(); + // If the field type is not provided, treat it as a comment + type = string.IsNullOrEmpty(type) ? comment : type; + value = match.Groups[nameof(value)].Value.Trim(); + data = match.Groups[nameof(data)].Value.Trim(); + + if ((type.Equals("event") && value.Equals("done") && data.Equals("[DONE]")) || + (type.Equals("data") && value.Equals("[DONE]"))) { - var line = lines[i]; + return; + } - if (!line.Contains(stopEventDelimiter)) - { - parameters.ServerSentEventCount++; - serverSentEventCallback.Invoke(line); - } + var eventStringBuilder = new StringBuilder(); + eventStringBuilder.Append("{"); + eventStringBuilder.Append($"\"{type}\":\"{value}\""); + + if (!string.IsNullOrWhiteSpace(data)) + { + eventStringBuilder.Append($",\"{nameof(data)}\":\"{data}\""); } + + eventStringBuilder.Append("}"); + serverSentEventCallback.Invoke(eventStringBuilder.ToString()); + parameters.ServerSentEventCount++; + parameters.ServerSentEvents.Add(new Tuple(type, value, data)); } } } diff --git a/Utilities.Rest/Packages/com.utilities.rest/Runtime/RestParameters.cs b/Utilities.Rest/Packages/com.utilities.rest/Runtime/RestParameters.cs index 486b520..18cb116 100644 --- a/Utilities.Rest/Packages/com.utilities.rest/Runtime/RestParameters.cs +++ b/Utilities.Rest/Packages/com.utilities.rest/Runtime/RestParameters.cs @@ -85,6 +85,8 @@ public RestParameters( internal int ServerSentEventCount { get; set; } + internal readonly List> ServerSentEvents = new(); + /// /// Cache downloaded content.
/// Default is true. diff --git a/Utilities.Rest/Packages/com.utilities.rest/package.json b/Utilities.Rest/Packages/com.utilities.rest/package.json index 4567a49..b812386 100644 --- a/Utilities.Rest/Packages/com.utilities.rest/package.json +++ b/Utilities.Rest/Packages/com.utilities.rest/package.json @@ -3,7 +3,7 @@ "displayName": "Utilities.Rest", "description": "This package contains useful RESTful utilities for the Unity Game Engine.", "keywords": [], - "version": "2.5.7", + "version": "3.0.0", "unity": "2021.3", "documentationUrl": "https://github.com/RageAgainstThePixel/com.utilities.rest#documentation", "changelogUrl": "https://github.com/RageAgainstThePixel/com.utilities.rest/releases", @@ -15,7 +15,7 @@ "author": "Stephen Hodgson", "url": "https://github.com/StephenHodgson", "dependencies": { - "com.utilities.async": "2.1.5", + "com.utilities.async": "2.1.6", "com.utilities.extensions": "1.1.15", "com.unity.modules.unitywebrequest": "1.0.0", "com.unity.modules.unitywebrequestassetbundle": "1.0.0", diff --git a/Utilities.Rest/Packages/manifest.json b/Utilities.Rest/Packages/manifest.json index 3120e2c..f445513 100644 --- a/Utilities.Rest/Packages/manifest.json +++ b/Utilities.Rest/Packages/manifest.json @@ -1,6 +1,6 @@ { "dependencies": { - "com.unity.addressables": "1.21.20", + "com.unity.addressables": "1.21.21", "com.unity.ide.rider": "3.0.28", "com.unity.ide.visualstudio": "2.0.22", "com.utilities.buildpipeline": "1.3.4"