GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
RESTHelper.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.IO;
20using System.Linq;
21using System.Net;
22using System.Net.Http;
23using System.Text;
24using System.Text.Json;
25using System.Threading.Tasks;
26using System.Web;
27using System.Windows.Forms;
28using System.Xml.Serialization;
30using Newtonsoft.Json;
31using YamlDotNet.Serialization;
32using static GenerallyPositive.Enums;
33
34namespace GenerallyPositive
35{
36 internal class RESTHelper
37 {
38 internal int StatusCode { get; set; }
39
40 // how long the server asked us to wait before asking again, in seconds. Zero when the response carried
41 // no Retry-After, which is every response that is not a 429 or a 503
42 internal int RetryAfterSeconds { get; set; }
43
44 private RESTClient _restClient;
45
46 // UseCookies left on hands the Cookie header to the handler, which then writes it from its own jar. the
47 // jar is new and empty on every call, so a Cookie the caller set is replaced by nothing and the request
48 // goes out without it. off, the header the caller set is the header that is sent
49 private HttpClient _httpClient { get; set; } = new HttpClient(new HttpClientHandler { UseCookies = false })
50 {
51 // HttpClient's own 100 second default outranks the token, so a caller asking for five minutes, or for
52 // no limit at all, was still cut off at 100 and told it was cancelled. Every send below is bounded by
53 // a CancellationTokenSource built from HttpTimeout, which this leaves as the only clock
54 Timeout = System.Threading.Timeout.InfiniteTimeSpan
55 };
56
57 private readonly string _browserExtensionApiBase;
58
59 // what one attempt is given when the caller did not say. long enough for a slow API to answer, short
60 // enough that a host which accepts the connection and never replies is not a two minute wait
61 internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
62
63 // settable after the base url, so a client handed a session by ContinueAsRESTClient can still be told how
64 // long to wait. See RESTClient.WithTimeout
65 internal TimeSpan HttpTimeout { get; set; } = DefaultTimeout;
66 internal static Dictionary<ApiEndpoint, string> Endpoints { get; set; } = new Dictionary<ApiEndpoint, string>
67 {
68 [ApiEndpoint.Back] = "back", // history.back()
69 [ApiEndpoint.CaptureVisibleTab] = "capture-visible-tab", // for headles image matching
70 [ApiEndpoint.CastDesktop] = "cast-desktop",
71 [ApiEndpoint.CastTab] = "cast-tab",
72 [ApiEndpoint.CheckNetworkIdle] = "check-network-idle", // ensure no more than 'maxConnections' are active - better than document.readyState in some scenarios
73 [ApiEndpoint.CheckStatus] = "status", // to test if the api is up
74 [ApiEndpoint.ClearReferrer] = "clear-referrer",
75 [ApiEndpoint.CloseBrowser] = "close-browser",
76 [ApiEndpoint.CloseTab] = "close-tab",
77 [ApiEndpoint.DeleteStorage] = "delete-storage",
78 [ApiEndpoint.DragAndDrop] = "drag-and-drop",
79 [ApiEndpoint.Evaluate] = "evaluate", // xpath
80 [ApiEndpoint.EvaluateAll] = "evaluate-all", // xpath multiple elements
81 [ApiEndpoint.EvaluatePersistent] = "evaluate-persistent", // xpath
82 [ApiEndpoint.EvaluateAllPersistent] = "evaluate-all-persistent", // xpath multiple elements
83 [ApiEndpoint.ExecuteJavaScript] = "execute-javascript",
84 [ApiEndpoint.Fetch] = "fetch",
85 [ApiEndpoint.FillInAppend] = "fill-in-append",
86 [ApiEndpoint.FillInInsert] = "fill-in-insert",
87 [ApiEndpoint.FillInOverwrite] = "fill-in-overwrite",
88 [ApiEndpoint.FireChangeEvent] = "fire-change-event",
89 [ApiEndpoint.Focus] = "focus",
90 [ApiEndpoint.Forward] = "forward", // history.forward()
91 [ApiEndpoint.FullScreen] = "fullscreen",
92 [ApiEndpoint.GetAttribute] = "get-attribute",
93 [ApiEndpoint.GetBoundingClientRect] = "get-bounding-client-rect",
94 [ApiEndpoint.ElementFromPoint] = "element-from-point",
95 [ApiEndpoint.GetContentAndCss] = "get-content-and-css",
96 [ApiEndpoint.GetCssAttributes] = "get-css-attributes",
97 [ApiEndpoint.GetCurrentUrl] = "get-current-url",
98 [ApiEndpoint.GetDocumentStamp] = "get-document-stamp",
99 [ApiEndpoint.GetHydrationScripts] = "get-hydration-scripts",
100 [ApiEndpoint.GetDomAttributes] = "get-dom-attributes",
101 [ApiEndpoint.GetDomProperties] = "get-dom-properties",
102 [ApiEndpoint.GetElementAttributeHash] = "get-element-attribute-hash",
103 [ApiEndpoint.GetPageSource] = "get-page-source",
104 [ApiEndpoint.GetParentNode] = "get-parent-node",
105 [ApiEndpoint.GetReadyStatus] = "get-ready-status",
106 [ApiEndpoint.GetShadowRoot] = "get-shadow-root",
107 [ApiEndpoint.GetStorage] = "get-storage",
108 [ApiEndpoint.GetLanguages] = "get-languages",
109 [ApiEndpoint.CaptureCalls] = "capture-calls",
110 [ApiEndpoint.GetCapturedCalls] = "get-captured-calls",
111 [ApiEndpoint.GetUserAgent] = "get-useragent",
112 [ApiEndpoint.GoTo] = "goto",
113 [ApiEndpoint.GoToTab] = "goto-tab",
114 [ApiEndpoint.GetWindowRectangle] = "get-window-rectangle",
115 [ApiEndpoint.HideElement] = "hide-element",
116 [ApiEndpoint.Hover] = "hover",
117 [ApiEndpoint.InjectScript] = "inject-script",
118 [ApiEndpoint.ClearInjectedScripts] = "clear-injected-scripts",
119 [ApiEndpoint.IsClickable] = "is-clickable",
120 [ApiEndpoint.IsDisplayed] = "is-displayed",
121 [ApiEndpoint.IsEnabled] = "is-enabled",
122 [ApiEndpoint.IsEndOfPage] = "is-end-of-page",
123 [ApiEndpoint.IsVisibleInViewport] = "is-visible-in-viewport",
124 [ApiEndpoint.LeftClick] = "left-click",
125 [ApiEndpoint.LeftClickAndDownload] = "left-click-download",
126 [ApiEndpoint.LeftDoubleClick] = "left-double-click",
127 [ApiEndpoint.Maximize] = "maximize",
128 [ApiEndpoint.MiddleClick] = "middle-click",
129 [ApiEndpoint.Minimize] = "minimize",
130 [ApiEndpoint.MoveTo] = "move-to",
131 [ApiEndpoint.NewTab] = "new-tab",
132 [ApiEndpoint.NextTab] = "next-tab",
133 [ApiEndpoint.NextWindow] = "next-window",
134 [ApiEndpoint.Normal] = "normal", // restore window after fullscreen
135 [ApiEndpoint.OverrideReferrer] = "override-referrer",
136 [ApiEndpoint.PageDown] = "page-down",
137 [ApiEndpoint.PageEnd] = "page-end",
138 [ApiEndpoint.PageTop] = "page-top",
139 [ApiEndpoint.PageUp] = "page-up",
140 [ApiEndpoint.PressModifierKey] = "press-modifier-key",
141 [ApiEndpoint.PreviousTab] = "previous-tab",
142 [ApiEndpoint.PreviousWindow] = "previous-window",
143 [ApiEndpoint.QuerySelector] = "query-selector", // css
144 [ApiEndpoint.QuerySelectors] = "query-selectors", // css multiple elements
145 [ApiEndpoint.QueryPersistentSelector] = "query-persistent-selector", // css
146 [ApiEndpoint.QueryPersistentSelectors] = "query-persistent-selectors", // css multiple elements
147 [ApiEndpoint.Refresh] = "refresh",
148 [ApiEndpoint.ReleaseModifierKey] = "release-modifier-key",
149 [ApiEndpoint.Restore] = "restore",
150 [ApiEndpoint.RightClick] = "right-click",
151 [ApiEndpoint.ScrollElement] = "scroll-element",
152 [ApiEndpoint.ScrollIntoView] = "scroll-into-view",
153 [ApiEndpoint.ScrollWindowByHorizontal] = "scroll-window",
154 [ApiEndpoint.ScrollWindowByVertical] = "scroll-window",
155 [ApiEndpoint.ScrollWindow] = "scroll-window",
156 [ApiEndpoint.SelectClick] = "select-click",
157 [ApiEndpoint.SendKey] = "send-key",
158 [ApiEndpoint.SendString] = "send-string",
159 [ApiEndpoint.SetAttribute] = "set-attribute",
160 [ApiEndpoint.SetDownloadFilename] = "set-download-filename",
161 [ApiEndpoint.SetGoogleReferrer] = "google-referrer",
162 [ApiEndpoint.SetRange] = "set-range",
163 [ApiEndpoint.SetStorage] = "set-storage",
164 [ApiEndpoint.SetUserAgent] = "set-useragent",
165 [ApiEndpoint.SetValueFromElement] = "set-value-from-element",
166 [ApiEndpoint.StopCasting] = "stop-casting",
167 [ApiEndpoint.SwitchToDefaultContent] = "switch-to-default-content",
168 [ApiEndpoint.SwitchToElement] = "switch-to-element",
169 [ApiEndpoint.SwitchToFrame] = "switch-to-frame", // NOTE: puppeteer only, not used by rest, ottomagic runs in every frame
170 [ApiEndpoint.SwitchToShadowRoot] = "switch-to-shadow-root",
171 [ApiEndpoint.TabCount] = "tab-count",
172 [ApiEndpoint.TopBrowser] = "top-browser",
173 [ApiEndpoint.MoveToPoint] = "move-to-point",
174 [ApiEndpoint.ClickPoint] = "click-point",
175 [ApiEndpoint.TrustedLeftClick] = "trusted-left-click",
176 [ApiEndpoint.TrustedMiddleClick] = "trusted-middle-click",
177 [ApiEndpoint.Upload] = "upload",
178 [ApiEndpoint.WindowInnerHeight] = "window-inner-height",
179 [ApiEndpoint.WindowInnerWidth] = "window-inner-width",
180 [ApiEndpoint.WindowOuterHeight] = "window-outer-height",
181 [ApiEndpoint.WindowOuterWidth] = "window-outer-width",
182 [ApiEndpoint.WindowPageOffsetX] = "window-page-xoffset",
183 [ApiEndpoint.WindowPageOffsetY] = "window-page-yoffset",
184 [ApiEndpoint.WindowScreenLeft] = "window-screen-left",
185 [ApiEndpoint.WindowScreenTop] = "window-screen-top",
186 [ApiEndpoint.OpenWindow] = "open-window",
187 [ApiEndpoint.GoToWindow] = "goto-window",
188 [ApiEndpoint.CloseWindow] = "close-window",
189 [ApiEndpoint.GetCurrentWindow] = "get-current-window",
190 #region GPAL STUDIO
191 [ApiEndpoint.GetWorkflow] = "get-workflow",
192 [ApiEndpoint.GetSettings] = "get-settings",
193 [ApiEndpoint.GetGpalSettings] = "get-gpal-settings",
194 [ApiEndpoint.GetBrowserSettings] = "get-browser-settings",
195 #endregion GPAL STUDIO
196 };
197
198 public RESTHelper(string browserExtensionApiBase, RESTClient client, TimeSpan? httpTimeout = null)
199 {
200 if (browserExtensionApiBase == null)
201 {
202 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
203 "Browser extension API base cannot be null.",
204 null, GPALObjectType.None);
205 }
206 _browserExtensionApiBase = browserExtensionApiBase;
207
208 // how long one attempt is given, from .WithAPIBase(url, timeout). Must be longer than the timeout in
209 // OttoMagic: query, select and get-content-and-css wait 10 seconds for a frame before answering with
210 // nothing. Timeout.InfiniteTimeSpan asks for no limit at all, which is what a long AI call needs
211 HttpTimeout = httpTimeout ?? DefaultTimeout;
212 _restClient = client;
213 }
214
215 public string GetEndpointUrl(ApiEndpoint endpoint)
216 {
217 return $"{_browserExtensionApiBase}{Endpoints[endpoint]}";
218 }
219
220 internal async Task<string> Call(ApiEndpoint endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
221 {
222 // no /status ahead of the real call. it doubled every call into two requests, and the second one
223 // arrived at the server while the first was still out at the extension. a server that is not there
224 // fails on the call itself, which says which call it was
225 string url = GetEndpointUrl(endpoint);
226
227 string result = await InternalCall(url, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
228
229 return result;
230 }
231
232 internal async Task<string> Call(string endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
233 {
234 // an endpoint that is already a whole url is used as it stands. a session carried out of a browser
235 // is scoped to the origin it was earned on, but the calls a page makes are not all to that origin,
236 // and appending one to the other produced a url belonging to neither
237 string url = true == endpoint?.StartsWith("http", StringComparison.OrdinalIgnoreCase)
238 ? endpoint
239 : $"{_browserExtensionApiBase}{endpoint}";
240
241 string result = await InternalCall(url, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
242
243 return result;
244 }
245
246 internal async Task<T> CallAndDeserialize<T>(ApiEndpoint endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
247 {
248// string url = GetEndpointUrl(endpoint);
249 string jsonResponse = await Call(endpoint, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
250 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{endpoint}] result: [{jsonResponse}]");
251
252 return string.IsNullOrEmpty(jsonResponse)
253 ? default
254 : JsonConvert.DeserializeObject<T>(jsonResponse); // system.text.json can't deserialize as cleanly
255 }
256
263 private static string DecodeJsonString(string response)
264 {
265 string retVal = response;
266
267 while (false == string.IsNullOrEmpty(retVal)
268 && true == retVal.StartsWith("\"") && true == retVal.EndsWith("\""))
269 {
270 try
271 {
272 string peeled = JsonConvert.DeserializeObject<string>(retVal);
273
274 // an encoding that decodes to itself is not an encoding, and looping on it never ends
275 if (peeled == retVal)
276 break;
277
278 retVal = peeled;
279 }
280 catch (Newtonsoft.Json.JsonException)
281 {
282 break; // not an encoded string after all, so it is the value already
283 }
284 }
285
286 return retVal;
287 }
288
289 internal async Task<T> CallAndDeserialize<T>(string endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
290 {
291 string jsonResponse = await Call(endpoint, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
292
293 // Trim took the quote characters off and left every escape inside them, so an encoded string
294 // holding a quote arrived broken and an encoded object arrived unparseable. one endpoint had
295 // its own unescape written for it because of this. decoding properly serves all of them
296 jsonResponse = DecodeJsonString(jsonResponse);
297
298 if (typeof(GPALElement) == typeof(T) || typeof(List<GPALElement>) == typeof(T))
299 {
300 var settings = new JsonSerializerSettings
301 {
302 Converters = new List<JsonConverter> { new GPALElementConverter(), new PointConverter(), new SizeConverter() },
303 NullValueHandling = NullValueHandling.Ignore
304 };
305 try
306 {
307 return JsonConvert.DeserializeObject<T>(jsonResponse, settings);
308 }
309 catch (Exception ex)
310 {
311 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Exception calling [{endpoint}]", null, GPALObjectType.None, ex);
312 }
313 }
314 else if(string.IsNullOrEmpty(jsonResponse))
315{
316 if (endpoint.Equals(Endpoints[ApiEndpoint.IsEndOfPage]) &&
317 typeof(T) == typeof(bool))
318 {
319 return (T)(object)true;
320 }
321
322 return default;
323 }
324
325 if (typeof(T) == typeof(bool))
326 if ("OK".Equals(jsonResponse) || "true".Equals(jsonResponse))
327 return (T)(object)true;
328 else if ("NOTOK".Equals(jsonResponse) || "false".Equals(jsonResponse))
329 return (T)(object)false;
330 else
331 {
332 var msg = jsonResponse.Replace("\\", "");
333 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{msg}]", parameters, GPALObjectType.Other);
334 return (T)(object)false;
335 }
336
337 // OttoMagic answers get-css-attributes with an empty array when the element is not in the dom, which is
338 // the ordinary answer to asking whether a hidden element is displayed, not something to report
339 if (endpoint.Equals(Endpoints[ApiEndpoint.GetCssAttributes]) && "[]".Equals(jsonResponse?.Trim()))
340 return default;
341
342 try
343 {
344 if (null != jsonResponse)
345 return JsonConvert.DeserializeObject<T>(jsonResponse);
346 else
347 return default;
348 }
349 catch (Newtonsoft.Json.JsonException ex)
350 {
351 // an endpoint that answers with a bare url or word is not answering with json, and for a caller
352 // asking for a string the response already is the answer. A quoted json string still goes through
353 // DeserializeObject above and arrives unquoted
354 if (typeof(T) == typeof(string))
355 return (T)(object)jsonResponse;
356
357 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
358 $"Could not deserialize [{jsonResponse?.Substring(0, Math.Min(80, jsonResponse?.Length ?? 0))}] as [{typeof(T).Name}]",
359 null, GPALObjectType.None, ex);
360 return default;
361 }
362 }
363
364 private static T HandleUnsupportedContentType<T>(string contentType, string response)
365 {
366 GPAL.PublishSimpleEvent(
367 GPALEventType.WARNING,
368 $"Unsupported format: [{contentType}]. Unable to cast to [{typeof(T)}]. Returning raw response.",
369 response,
370 GPALObjectType.Other);
371 return default;
372 }
373
374 // TODO: add out param to return the server status code
375 // the headers .net insists live on HttpContent. everything else is the request's
376 private static readonly string[] contentHeaders =
377 {
378 "content-type", "content-length", "content-encoding", "content-language", "content-location",
379 "content-md5", "content-range", "content-disposition", "expires", "last-modified", "allow"
380 };
381
382 private static bool IsContentHeader(string name)
383 {
384 return Array.Exists(contentHeaders, content => content.Equals(name, StringComparison.OrdinalIgnoreCase));
385 }
386
396 private static string AppendQueryString(string route, object parameters)
397 {
398 string query;
399
400 if (parameters is string written)
401 {
402 query = written.TrimStart('?', '&');
403 }
404 else
405 {
406 IEnumerable<KeyValuePair<string, object>> pairs;
407
408 if (parameters is IDictionary<string, object> dictionary)
409 pairs = dictionary;
410 else
411 pairs = parameters.GetType().GetProperties()
412 .Select(property => new KeyValuePair<string, object>(property.Name, property.GetValue(parameters)));
413
414 query = string.Join("&", pairs
415 .Where(pair => null != pair.Value)
416 .Select(pair => $"{HttpUtility.UrlEncode(pair.Key)}={HttpUtility.UrlEncode(pair.Value.ToString())}"));
417 }
418
419 string retVal = route;
420
421 if (false == string.IsNullOrEmpty(query))
422 retVal = true == route.Contains("?") ? $"{route}&{query}" : $"{route}?{query}";
423
424 return retVal;
425 }
426
432 private static int ReadRetryAfter(HttpResponseMessage response)
433 {
434 int seconds = 0;
435
436 // the header is sent either as a count of seconds or as the date the wait is over, and both forms
437 // are legal on the same endpoint, so both are read
438 if (null != response?.Headers?.RetryAfter)
439 {
440 if (true == response.Headers.RetryAfter.Delta.HasValue)
441 seconds = (int)Math.Ceiling(response.Headers.RetryAfter.Delta.Value.TotalSeconds);
442 else if (true == response.Headers.RetryAfter.Date.HasValue)
443 seconds = (int)Math.Ceiling((response.Headers.RetryAfter.Date.Value - DateTimeOffset.UtcNow).TotalSeconds);
444 }
445
446 return 0 < seconds ? seconds : 0;
447 }
448
449 internal async Task<string> InternalCall(string route, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
450 {
451 string upperMethod = httpMethod.ToUpperInvariant();
452
453 // a GET or a HEAD says what it is asking about in the url. .net will not put a body on either one, so
454 // parameters attached as content throw before the request leaves, and the caller is told the server
455 // never answered when nothing was ever sent. they go on the query string instead, which leaves the
456 // content branch below with nothing to do
457 if (("GET" == upperMethod || "HEAD" == upperMethod) && null != parameters)
458 {
459 route = AppendQueryString(route, parameters);
460 parameters = null;
461 }
462
463 // === RETRY ONCE ON TIMEOUT ===
464 async Task<string> TryOnce()
465 {
466 using var cts = new System.Threading.CancellationTokenSource(HttpTimeout);
467 HttpResponseMessage httpResponse = new HttpResponseMessage();
468 string responseBody = "";
469
470 try
471 {
472 if (httpMethod.ToUpper() == "POST" &&
473 route.EndsWith("upload", StringComparison.OrdinalIgnoreCase))
474 {
475 var paramsDict = parameters as IDictionary<string, object>
476 ?? parameters?.GetType().GetProperties()
477 .ToDictionary(p => p.Name, p => p.GetValue(parameters));
478
479 paramsDict.TryGetValue("uploadPath", out var uploadPathObj);
480 string uploadPath = uploadPathObj?.ToString();
481
482 paramsDict.TryGetValue("elementId", out var elementIdObj);
483 string elementId = elementIdObj?.ToString();
484
485 if (!File.Exists(uploadPath))
486 {
487 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
488 $"upload file not found: [{uploadPath}]", null, GPALObjectType.None);
489 return "";
490 }
491
492 byte[] fileBytes = File.ReadAllBytes(uploadPath);
493 string base64Data = Convert.ToBase64String(fileBytes);
494 string mimeType = MimeMapping.GetMimeMapping(uploadPath);
495
496 var payload = new
497 {
498 elementId,
499 uploadPath,
500 mimeType,
501 base64Data
502 };
503
504 string jsonBody = System.Text.Json.JsonSerializer.Serialize(payload);
505 HttpContent content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
506
507 var attachRequest = new HttpRequestMessage(HttpMethod.Post, route);
508 attachRequest.Content = content;
509
510 httpResponse = await _httpClient.SendAsync(attachRequest, cts.Token).ConfigureAwait(false);
511 }
512 else
513 {
514 var request = new HttpRequestMessage(new HttpMethod(httpMethod.ToUpperInvariant()), route);
515
516 request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
517
518 foreach (var header in _restClient._headers)
519 {
520 // a content header belongs on the content, and .net throws "misused header name"
521 // rather than ignoring one put on the request. compared without case, because a
522 // header copied from a page arrives however that page wrote it
523 if (false == IsContentHeader(header.Key) && !request.Headers.Contains(header.Key))
524 {
525 request.Headers.TryAddWithoutValidation(header.Key, header.Value);
526 }
527 }
528
529 if (parameters != null)
530 {
531 // a Content-Type set by the caller is the one to send. it is skipped above because
532 // it belongs on the content rather than the request, not because it is unwanted, and
533 // a site that asks for form-urlencoded while sending json means it: replaying that
534 // as json is a different request
535 string contentType = RESTClient.GetContentType(contentEncoding);
536
537 foreach (var header in _restClient._headers)
538 if (true == "Content-Type".Equals(header.Key, StringComparison.OrdinalIgnoreCase)
539 && false == string.IsNullOrWhiteSpace(header.Value))
540 contentType = header.Value.Split(';')[0].Trim(); // the charset is StringContent's to add
541
542 object parms = parameters;
543
544 string parmsEncodingType = StringFormatDetector.DetectFormat(parameters.ToString());
545
546 if ("none".Equals(parmsEncodingType) && "application/json".Equals(contentType))
547 {
548 parms = System.Text.Json.JsonSerializer.Serialize(parameters);
549 }
550 else if ("none".Equals(parmsEncodingType) && "application/x-www-form-urlencoded".Equals(contentType))
551 {
552 if (!(parameters is string))
553 parms = HttpUtility.UrlEncode(parameters.ToString());
554 }
555
556 string bodyText = parms?.ToString() ?? "";
557 request.Content = new StringContent(bodyText, System.Text.Encoding.UTF8, contentType);
558 }
559
560 httpResponse = await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
561 }
562
563 StatusCode = (int)httpResponse.StatusCode;
564 RetryAfterSeconds = ReadRetryAfter(httpResponse);
565
566 responseBody = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
567
568 // truncate the raw body for the debug event - this is only to get a sense of the contents when
569 // diagnosing downstream issues, not a full dump. Responses can be tens of MB, and interpolating
570 // the whole thing builds a giant string on every call, so cap it and note the real length.
571 const int maxRawLogChars = 200;
572 string rawForLog = string.IsNullOrEmpty(responseBody) || responseBody.Length <= maxRawLogChars
573 ? responseBody
574 : responseBody.Substring(0, maxRawLogChars) + $"...[truncated, {responseBody.Length} chars total]";
575
576 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"raw result[{route}][{rawForLog}]", this, GPALObjectType.RestHelper);
577
578 string upperMethod = httpMethod.ToUpperInvariant();
579 if (string.IsNullOrEmpty(responseBody) &&
580 (upperMethod == "OPTIONS" || upperMethod == "HEAD"))
581 {
582 var headerDict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
583
584 foreach (var h in httpResponse.Headers)
585 headerDict[h.Key] = string.Join(", ", h.Value);
586
587 foreach (var h in httpResponse.Content.Headers)
588 if (!headerDict.ContainsKey(h.Key))
589 headerDict[h.Key] = string.Join(", ", h.Value);
590
591 responseBody = System.Text.Json.JsonSerializer.Serialize(headerDict,
592 new JsonSerializerOptions { WriteIndented = true });
593 }
594
595 if (false == httpResponse.IsSuccessStatusCode)
596 {
597 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
598 $"Request [{route}] failed with status code: [{StatusCode}] [{httpResponse.ReasonPhrase}]",
599 responseBody, GPALObjectType.None);
600 }
601
602 return responseBody;
603 }
604 catch (Exception ex)
605 {
606 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
607 $"Error in TryOnce for [{httpMethod}] [{route}]", null, GPALObjectType.RestClient, ex);
608 throw;
609 }
610 }
611
612 // === TRY TWICE ===
613 try
614 {
615 return await TryOnce().ConfigureAwait(false);
616 }
617 // a transport error that came and went is worth asking again. a cancellation is the attempt having
618 // spent the whole timeout without an answer, and asking again only spends it a second time, so the
619 // host that accepts a connection and never replies costs one timeout rather than two
620 catch (Exception first) when (false == IsCancellation(first))
621 {
622 try
623 {
624 await Task.Delay(2_000).ConfigureAwait(false);
625 return await TryOnce().ConfigureAwait(false); // second attempt
626 }
627 catch (Exception ex)
628 {
629 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
630 $"Unable to communicate with: [{route}]",
631 null, GPALObjectType.None, ex);
632
633 // this browser's own endpoint, unreachable twice, is the browser being gone, and there is
634 // nothing left for the workflow to decide. a third party service that is down is the
635 // workflow's business, so that still answers with nothing and the workflow chooses
636 if (true == IsOurBrowser(route))
637 throw new GPALException($"{GPAL.MyMethodName()}: The browser at [{_browserExtensionApiBase}] did not answer [{route}] on either attempt, so there is nothing to drive");
638
639 return "";
640 }
641 }
642 catch (Exception timedOut)
643 {
644 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
645 $"Nothing answered [{route}] in [{TimeoutText()}], so the call was given up on",
646 null, GPALObjectType.None, timedOut);
647
648 if (true == IsOurBrowser(route))
649 throw new GPALException($"{GPAL.MyMethodName()}: The browser at [{_browserExtensionApiBase}] did not answer [{route}] in [{TimeoutText()}], so there is nothing to drive");
650
651 return "";
652 }
653 }
662 private static bool IsCancellation(Exception ex)
663 {
664 bool retVal = ex is OperationCanceledException || ex is TimeoutException;
665
666 if (false == retVal && ex is HttpRequestException && null != ex.InnerException)
667 retVal = ex.InnerException is OperationCanceledException || ex.InnerException is TimeoutException;
668
669 return retVal;
670 }
671
676 private string TimeoutText()
677 {
678 return System.Threading.Timeout.InfiniteTimeSpan == HttpTimeout
679 ? "no limit"
680 : $"{HttpTimeout.TotalSeconds:0.#} seconds";
681 }
682
693 private bool IsOurBrowser(string route)
694 {
695 bool retVal = false;
696
697 if (false == string.IsNullOrEmpty(_browserExtensionApiBase)
698 && true == route.StartsWith(_browserExtensionApiBase, StringComparison.OrdinalIgnoreCase)
699 && true == Uri.TryCreate(_browserExtensionApiBase, UriKind.Absolute, out Uri baseUri)
700 && true == baseUri.IsLoopback)
701 {
702 string called = route.Substring(_browserExtensionApiBase.Length).Split('?')[0].Trim('/');
703
704 retVal = Endpoints.Values.Any(known => known.Trim('/').Equals(called, StringComparison.OrdinalIgnoreCase));
705 }
706
707 return retVal;
708 }
709
710 public async Task<bool> CheckAPIStatus()
711 {
712 string route = $"{_browserExtensionApiBase}{Endpoints[ApiEndpoint.CheckStatus]}";
713 using (HttpClient client = new HttpClient())
714 {
715 try
716 {
717 HttpResponseMessage response = await client.GetAsync(route).ConfigureAwait(false);
718 string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
719
720 return responseBody.Equals("Server is running");
721 }
722 catch (Exception)
723 {
724 return false;
725 }
726 }
727 }
728 }
729 public static class StringFormatDetector
730 {
731 public static string DetectFormat(string input)
732 {
733 if (string.IsNullOrWhiteSpace(input))
734 return "none";
735
736 // Trim to avoid false negatives from whitespace
737 var trimmed = input.Trim();
738
739 // Check JSON
740 if (IsJson(trimmed))
741 return "json";
742
743 // Check URL encoding
744 if (IsUrlEncoded(trimmed))
745 return "url";
746
747 return "none";
748 }
749
750 internal static bool IsJson(string input)
751 {
752 if ((input.StartsWith("{") && input.EndsWith("}")) ||
753 (input.StartsWith("[") && input.EndsWith("]")))
754 {
755 try
756 {
757 using var doc = JsonDocument.Parse(input);
758 return true;
759 }
760 catch
761 {
762 return false;
763 }
764 }
765 return false;
766 }
767
768 private static bool IsUrlEncoded(string input)
769 {
770 try
771 {
772 var decoded = HttpUtility.UrlDecode(input);
773 var reencoded = HttpUtility.UrlEncode(decoded);
774 return string.Equals(input, reencoded, StringComparison.Ordinal);
775 }
776 catch
777 {
778 return false;
779 }
780 }
781 }
782}