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 private RESTClient _restClient;
41 // UseCookies left on hands the Cookie header to the handler, which then writes it from its own jar. the
42 // jar is new and empty on every call, so a Cookie the caller set is replaced by nothing and the request
43 // goes out without it. off, the header the caller set is the header that is sent
44 private HttpClient _httpClient { get; set; } = new HttpClient(new HttpClientHandler { UseCookies = false });
45
46 private readonly string _browserExtensionApiBase;
47 internal static Dictionary<ApiEndpoint, string> Endpoints { get; set; } = new Dictionary<ApiEndpoint, string>
48 {
49 [ApiEndpoint.Back] = "back", // history.back()
50 [ApiEndpoint.CaptureVisibleTab] = "capture-visible-tab", // for headles image matching
51 [ApiEndpoint.CastDesktop] = "cast-desktop",
52 [ApiEndpoint.CastTab] = "cast-tab",
53 [ApiEndpoint.CheckNetworkIdle] = "check-network-idle", // ensure no more than 'maxConnections' are active - better than document.readyState in some scenarios
54 [ApiEndpoint.CheckStatus] = "status", // to test if the api is up
55 [ApiEndpoint.ClearReferrer] = "clear-referrer",
56 [ApiEndpoint.CloseBrowser] = "close-browser",
57 [ApiEndpoint.CloseTab] = "close-tab",
58 [ApiEndpoint.DeleteStorage] = "delete-storage",
59 [ApiEndpoint.DragAndDrop] = "drag-and-drop",
60 [ApiEndpoint.Evaluate] = "evaluate", // xpath
61 [ApiEndpoint.EvaluateAll] = "evaluate-all", // xpath multiple elements
62 [ApiEndpoint.EvaluatePersistent] = "evaluate-persistent", // xpath
63 [ApiEndpoint.EvaluateAllPersistent] = "evaluate-all-persistent", // xpath multiple elements
64 [ApiEndpoint.ExecuteJavaScript] = "execute-javascript",
65 [ApiEndpoint.Fetch] = "fetch",
66 [ApiEndpoint.FillInAppend] = "fill-in-append",
67 [ApiEndpoint.FillInInsert] = "fill-in-insert",
68 [ApiEndpoint.FillInOverwrite] = "fill-in-overwrite",
69 [ApiEndpoint.FireChangeEvent] = "fire-change-event",
70 [ApiEndpoint.Focus] = "focus",
71 [ApiEndpoint.Forward] = "forward", // history.forward()
72 [ApiEndpoint.FullScreen] = "fullscreen",
73 [ApiEndpoint.GetAttribute] = "get-attribute",
74 [ApiEndpoint.GetBoundingClientRect] = "get-bounding-client-rect",
75 [ApiEndpoint.ElementFromPoint] = "element-from-point",
76 [ApiEndpoint.GetContentAndCss] = "get-content-and-css",
77 [ApiEndpoint.GetCssAttributes] = "get-css-attributes",
78 [ApiEndpoint.GetCurrentUrl] = "get-current-url",
79 [ApiEndpoint.GetDomAttributes] = "get-dom-attributes",
80 [ApiEndpoint.GetDomProperties] = "get-dom-properties",
81 [ApiEndpoint.GetElementAttributeHash] = "get-element-attribute-hash",
82 [ApiEndpoint.GetPageSource] = "get-page-source",
83 [ApiEndpoint.GetParentNode] = "get-parent-node",
84 [ApiEndpoint.GetReadyStatus] = "get-ready-status",
85 [ApiEndpoint.GetShadowRoot] = "get-shadow-root",
86 [ApiEndpoint.GetStorage] = "get-storage",
87 [ApiEndpoint.GetLanguages] = "get-languages",
88 [ApiEndpoint.CaptureCalls] = "capture-calls",
89 [ApiEndpoint.GetCapturedCalls] = "get-captured-calls",
90 [ApiEndpoint.GetUserAgent] = "get-useragent",
91 [ApiEndpoint.GoTo] = "goto",
92 [ApiEndpoint.GoToTab] = "goto-tab",
93 [ApiEndpoint.GetWindowRectangle] = "get-window-rectangle",
94 [ApiEndpoint.HideElement] = "hide-element",
95 [ApiEndpoint.Hover] = "hover",
96 [ApiEndpoint.InjectScript] = "inject-script",
97 [ApiEndpoint.ClearInjectedScripts] = "clear-injected-scripts",
98 [ApiEndpoint.IsClickable] = "is-clickable",
99 [ApiEndpoint.IsDisplayed] = "is-displayed",
100 [ApiEndpoint.IsEnabled] = "is-enabled",
101 [ApiEndpoint.IsEndOfPage] = "is-end-of-page",
102 [ApiEndpoint.IsVisibleInViewport] = "is-visible-in-viewport",
103 [ApiEndpoint.LeftClick] = "left-click",
104 [ApiEndpoint.LeftClickAndDownload] = "left-click-download",
105 [ApiEndpoint.LeftDoubleClick] = "left-double-click",
106 [ApiEndpoint.Maximize] = "maximize",
107 [ApiEndpoint.MiddleClick] = "middle-click",
108 [ApiEndpoint.Minimize] = "minimize",
109 [ApiEndpoint.MoveTo] = "move-to",
110 [ApiEndpoint.NewTab] = "new-tab",
111 [ApiEndpoint.NextTab] = "next-tab",
112 [ApiEndpoint.NextWindow] = "next-window",
113 [ApiEndpoint.Normal] = "normal", // restore window after fullscreen
114 [ApiEndpoint.OverrideReferrer] = "override-referrer",
115 [ApiEndpoint.PageDown] = "page-down",
116 [ApiEndpoint.PageEnd] = "page-end",
117 [ApiEndpoint.PageTop] = "page-top",
118 [ApiEndpoint.PageUp] = "page-up",
119 [ApiEndpoint.PressModifierKey] = "press-modifier-key",
120 [ApiEndpoint.PreviousTab] = "previous-tab",
121 [ApiEndpoint.PreviousWindow] = "previous-window",
122 [ApiEndpoint.QuerySelector] = "query-selector", // css
123 [ApiEndpoint.QuerySelectors] = "query-selectors", // css multiple elements
124 [ApiEndpoint.QueryPersistentSelector] = "query-persistent-selector", // css
125 [ApiEndpoint.QueryPersistentSelectors] = "query-persistent-selectors", // css multiple elements
126 [ApiEndpoint.Refresh] = "refresh",
127 [ApiEndpoint.ReleaseModifierKey] = "release-modifier-key",
128 [ApiEndpoint.Restore] = "restore",
129 [ApiEndpoint.RightClick] = "right-click",
130 [ApiEndpoint.RightClickAndDownload] = "right-click-download",
131 [ApiEndpoint.ScrollElement] = "scroll-element",
132 [ApiEndpoint.ScrollIntoView] = "scroll-into-view",
133 [ApiEndpoint.ScrollWindowByHorizontal] = "scroll-window",
134 [ApiEndpoint.ScrollWindowByVertical] = "scroll-window",
135 [ApiEndpoint.ScrollWindow] = "scroll-window",
136 [ApiEndpoint.SelectClick] = "select-click",
137 [ApiEndpoint.SendKey] = "send-key",
138 [ApiEndpoint.SendString] = "send-string",
139 [ApiEndpoint.SetAttribute] = "set-attribute",
140 [ApiEndpoint.SetDownloadFilename] = "set-download-filename",
141 [ApiEndpoint.SetRange] = "set-range",
142 [ApiEndpoint.SetStorage] = "set-storage",
143 [ApiEndpoint.SetUserAgent] = "set-useragent",
144 [ApiEndpoint.SetValueFromElement] = "set-value-from-element",
145 [ApiEndpoint.StealthOverrideReferrer] = "stealth-override-referrer",
146 [ApiEndpoint.StopCasting] = "stop-casting",
147 [ApiEndpoint.SubmitForm] = "submit-form",
148 [ApiEndpoint.SwitchToDefaultContent] = "switch-to-default-content",
149 [ApiEndpoint.SwitchToElement] = "switch-to-element",
150 [ApiEndpoint.SwitchToFrame] = "switch-to-frame", // NOTE: puppeteer only, not used by rest, ottomagic runs in every frame
151 [ApiEndpoint.SwitchToShadowRoot] = "switch-to-shadow-root",
152 [ApiEndpoint.TabCount] = "tab-count",
153 [ApiEndpoint.TopBrowser] = "top-browser",
154 [ApiEndpoint.MoveToPoint] = "move-to-point",
155 [ApiEndpoint.ClickPoint] = "click-point",
156 [ApiEndpoint.TrustedLeftClick] = "trusted-left-click",
157 [ApiEndpoint.Upload] = "upload",
158 [ApiEndpoint.WindowInnerHeight] = "window-inner-height",
159 [ApiEndpoint.WindowInnerWidth] = "window-inner-width",
160 [ApiEndpoint.WindowOuterHeight] = "window-outer-height",
161 [ApiEndpoint.WindowOuterWidth] = "window-outer-width",
162 [ApiEndpoint.WindowPageOffsetX] = "window-page-xoffset",
163 [ApiEndpoint.WindowPageOffsetY] = "window-page-yoffset",
164 [ApiEndpoint.WindowScreenLeft] = "window-screen-left",
165 [ApiEndpoint.WindowScreenTop] = "window-screen-top",
166 [ApiEndpoint.OpenWindow] = "open-window",
167 [ApiEndpoint.GoToWindow] = "goto-window",
168 [ApiEndpoint.CloseWindow] = "close-window",
169 [ApiEndpoint.GetCurrentWindow] = "get-current-window",
170 #region GPAL STUDIO
171 [ApiEndpoint.GetWorkflow] = "get-workflow",
172 [ApiEndpoint.GetSettings] = "get-settings",
173 [ApiEndpoint.GetGpalSettings] = "get-gpal-settings",
174 [ApiEndpoint.GetBrowserSettings] = "get-browser-settings",
175 #endregion GPAL STUDIO
176 };
177
178 public RESTHelper(string browserExtensionApiBase, RESTClient client, TimeSpan? httpTimeout = null)
179 {
180 if (browserExtensionApiBase == null)
181 {
182 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
183 "Browser extension API base cannot be null.",
184 null, GPALObjectType.None);
185 }
186 _browserExtensionApiBase = browserExtensionApiBase;
187 // must be longer than timeout in ottomagic - for query/select/get-content-and-css calls the timeout is 10 seconds before we get an empty response if no frame responds
188// _httpClient.Timeout = httpTimeout ?? TimeSpan.FromSeconds(15); // use default with is 100s, we use a cancellation token elsewhere
189 _restClient = client;
190 }
191
192 public string GetEndpointUrl(ApiEndpoint endpoint)
193 {
194 return $"{_browserExtensionApiBase}{Endpoints[endpoint]}";
195 }
196
197 internal async Task<string> Call(ApiEndpoint endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
198 {
199 // no /status ahead of the real call. it doubled every call into two requests, and the second one
200 // arrived at the server while the first was still out at the extension. a server that is not there
201 // fails on the call itself, which says which call it was
202 string url = GetEndpointUrl(endpoint);
203
204 string result = await InternalCall(url, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
205
206 return result;
207 }
208
209 internal async Task<string> Call(string endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
210 {
211 // an endpoint that is already a whole url is used as it stands. a session carried out of a browser
212 // is scoped to the origin it was earned on, but the calls a page makes are not all to that origin,
213 // and appending one to the other produced a url belonging to neither
214 string url = true == endpoint?.StartsWith("http", StringComparison.OrdinalIgnoreCase)
215 ? endpoint
216 : $"{_browserExtensionApiBase}{endpoint}";
217
218 string result = await InternalCall(url, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
219
220 return result;
221 }
222
223 internal async Task<T> CallAndDeserialize<T>(ApiEndpoint endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
224 {
225// string url = GetEndpointUrl(endpoint);
226 string jsonResponse = await Call(endpoint, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
227 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{endpoint}] result: [{jsonResponse}]");
228
229 return string.IsNullOrEmpty(jsonResponse)
230 ? default
231 : JsonConvert.DeserializeObject<T>(jsonResponse); // system.text.json can't deserialize as cleanly
232 }
233
240 private static string DecodeJsonString(string response)
241 {
242 string retVal = response;
243
244 while (false == string.IsNullOrEmpty(retVal)
245 && true == retVal.StartsWith("\"") && true == retVal.EndsWith("\""))
246 {
247 try
248 {
249 string peeled = JsonConvert.DeserializeObject<string>(retVal);
250
251 // an encoding that decodes to itself is not an encoding, and looping on it never ends
252 if (peeled == retVal)
253 break;
254
255 retVal = peeled;
256 }
257 catch (Newtonsoft.Json.JsonException)
258 {
259 break; // not an encoded string after all, so it is the value already
260 }
261 }
262
263 return retVal;
264 }
265
266 internal async Task<T> CallAndDeserialize<T>(string endpoint, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
267 {
268 string jsonResponse = await Call(endpoint, parameters, httpMethod, contentEncoding).ConfigureAwait(false);
269
270 // Trim took the quote characters off and left every escape inside them, so an encoded string
271 // holding a quote arrived broken and an encoded object arrived unparseable. one endpoint had
272 // its own unescape written for it because of this. decoding properly serves all of them
273 jsonResponse = DecodeJsonString(jsonResponse);
274
275 if (typeof(GPALElement) == typeof(T) || typeof(List<GPALElement>) == typeof(T))
276 {
277 var settings = new JsonSerializerSettings
278 {
279 Converters = new List<JsonConverter> { new GPALElementConverter(), new PointConverter(), new SizeConverter() },
280 NullValueHandling = NullValueHandling.Ignore
281 };
282 try
283 {
284 return JsonConvert.DeserializeObject<T>(jsonResponse, settings);
285 }
286 catch (Exception ex)
287 {
288 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Exception calling [{endpoint}]", null, GPALObjectType.None, ex);
289 }
290 }
291 else if(string.IsNullOrEmpty(jsonResponse))
292{
293 if (endpoint.Equals(Endpoints[ApiEndpoint.IsEndOfPage]) &&
294 typeof(T) == typeof(bool))
295 {
296 return (T)(object)true;
297 }
298
299 return default;
300 }
301
302 if (typeof(T) == typeof(bool))
303 if ("OK".Equals(jsonResponse) || "true".Equals(jsonResponse))
304 return (T)(object)true;
305 else if ("NOTOK".Equals(jsonResponse) || "false".Equals(jsonResponse))
306 return (T)(object)false;
307 else
308 {
309 var msg = jsonResponse.Replace("\\", "");
310 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{msg}]", parameters, GPALObjectType.Other);
311 return (T)(object)false;
312 }
313
314 // OttoMagic answers get-css-attributes with an empty array when the element is not in the dom, which is
315 // the ordinary answer to asking whether a hidden element is displayed, not something to report
316 if (endpoint.Equals(Endpoints[ApiEndpoint.GetCssAttributes]) && "[]".Equals(jsonResponse?.Trim()))
317 return default;
318
319 try
320 {
321 if (null != jsonResponse)
322 return JsonConvert.DeserializeObject<T>(jsonResponse);
323 else
324 return default;
325 }
326 catch (Newtonsoft.Json.JsonException ex)
327 {
328 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
329 $"Could not deserialize [{jsonResponse?.Substring(0, Math.Min(80, jsonResponse?.Length ?? 0))}] as [{typeof(T).Name}]",
330 null, GPALObjectType.None, ex);
331 return default;
332 }
333 }
334
335 private static T HandleUnsupportedContentType<T>(string contentType, string response)
336 {
337 GPAL.PublishSimpleEvent(
338 GPALEventType.WARNING,
339 $"Unsupported format: [{contentType}]. Unable to cast to [{typeof(T)}]. Returning raw response.",
340 response,
341 GPALObjectType.Other);
342 return default;
343 }
344
345 // TODO: add out param to return the server status code
346 // the headers .net insists live on HttpContent. everything else is the request's
347 private static readonly string[] contentHeaders =
348 {
349 "content-type", "content-length", "content-encoding", "content-language", "content-location",
350 "content-md5", "content-range", "content-disposition", "expires", "last-modified", "allow"
351 };
352
353 private static bool IsContentHeader(string name)
354 {
355 return Array.Exists(contentHeaders, content => content.Equals(name, StringComparison.OrdinalIgnoreCase));
356 }
357
367 private static string AppendQueryString(string route, object parameters)
368 {
369 string query;
370
371 if (parameters is string written)
372 {
373 query = written.TrimStart('?', '&');
374 }
375 else
376 {
377 IEnumerable<KeyValuePair<string, object>> pairs;
378
379 if (parameters is IDictionary<string, object> dictionary)
380 pairs = dictionary;
381 else
382 pairs = parameters.GetType().GetProperties()
383 .Select(property => new KeyValuePair<string, object>(property.Name, property.GetValue(parameters)));
384
385 query = string.Join("&", pairs
386 .Where(pair => null != pair.Value)
387 .Select(pair => $"{HttpUtility.UrlEncode(pair.Key)}={HttpUtility.UrlEncode(pair.Value.ToString())}"));
388 }
389
390 string retVal = route;
391
392 if (false == string.IsNullOrEmpty(query))
393 retVal = true == route.Contains("?") ? $"{route}&{query}" : $"{route}?{query}";
394
395 return retVal;
396 }
397
398 internal async Task<string> InternalCall(string route, object parameters = null, string httpMethod = "GET", ContentEncoding contentEncoding = ContentEncoding.Json)
399 {
400 string upperMethod = httpMethod.ToUpperInvariant();
401
402 // a GET or a HEAD says what it is asking about in the url. .net will not put a body on either one, so
403 // parameters attached as content throw before the request leaves, and the caller is told the server
404 // never answered when nothing was ever sent. they go on the query string instead, which leaves the
405 // content branch below with nothing to do
406 if (("GET" == upperMethod || "HEAD" == upperMethod) && null != parameters)
407 {
408 route = AppendQueryString(route, parameters);
409 parameters = null;
410 }
411
412 // === RETRY ONCE ON TIMEOUT ===
413 async Task<string> TryOnce()
414 {
415 using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(60));
416 HttpResponseMessage httpResponse = new HttpResponseMessage();
417 string responseBody = "";
418
419 try
420 {
421 if (httpMethod.ToUpper() == "POST" &&
422 route.EndsWith("upload", StringComparison.OrdinalIgnoreCase))
423 {
424 var paramsDict = parameters as IDictionary<string, object>
425 ?? parameters?.GetType().GetProperties()
426 .ToDictionary(p => p.Name, p => p.GetValue(parameters));
427
428 paramsDict.TryGetValue("uploadPath", out var uploadPathObj);
429 string uploadPath = uploadPathObj?.ToString();
430
431 paramsDict.TryGetValue("elementId", out var elementIdObj);
432 string elementId = elementIdObj?.ToString();
433
434 if (!File.Exists(uploadPath))
435 {
436 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
437 $"upload file not found: [{uploadPath}]", null, GPALObjectType.None);
438 return "";
439 }
440
441 byte[] fileBytes = File.ReadAllBytes(uploadPath);
442 string base64Data = Convert.ToBase64String(fileBytes);
443 string mimeType = MimeMapping.GetMimeMapping(uploadPath);
444
445 var payload = new
446 {
447 elementId,
448 uploadPath,
449 mimeType,
450 base64Data
451 };
452
453 string jsonBody = System.Text.Json.JsonSerializer.Serialize(payload);
454 HttpContent content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
455
456 var attachRequest = new HttpRequestMessage(HttpMethod.Post, route);
457 attachRequest.Content = content;
458
459 httpResponse = await _httpClient.SendAsync(attachRequest, cts.Token).ConfigureAwait(false);
460 }
461 else
462 {
463 var request = new HttpRequestMessage(new HttpMethod(httpMethod.ToUpperInvariant()), route);
464
465 request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
466
467 foreach (var header in _restClient._headers)
468 {
469 // a content header belongs on the content, and .net throws "misused header name"
470 // rather than ignoring one put on the request. compared without case, because a
471 // header copied from a page arrives however that page wrote it
472 if (false == IsContentHeader(header.Key) && !request.Headers.Contains(header.Key))
473 {
474 request.Headers.TryAddWithoutValidation(header.Key, header.Value);
475 }
476 }
477
478 if (parameters != null)
479 {
480 // a Content-Type set by the caller is the one to send. it is skipped above because
481 // it belongs on the content rather than the request, not because it is unwanted, and
482 // a site that asks for form-urlencoded while sending json means it: replaying that
483 // as json is a different request
484 string contentType = RESTClient.GetContentType(contentEncoding);
485
486 foreach (var header in _restClient._headers)
487 if (true == "Content-Type".Equals(header.Key, StringComparison.OrdinalIgnoreCase)
488 && false == string.IsNullOrWhiteSpace(header.Value))
489 contentType = header.Value.Split(';')[0].Trim(); // the charset is StringContent's to add
490
491 object parms = parameters;
492
493 string parmsEncodingType = StringFormatDetector.DetectFormat(parameters.ToString());
494
495 if ("none".Equals(parmsEncodingType) && "application/json".Equals(contentType))
496 {
497 parms = System.Text.Json.JsonSerializer.Serialize(parameters);
498 }
499 else if ("none".Equals(parmsEncodingType) && "application/x-www-form-urlencoded".Equals(contentType))
500 {
501 if (!(parameters is string))
502 parms = HttpUtility.UrlEncode(parameters.ToString());
503 }
504
505 string bodyText = parms?.ToString() ?? "";
506 request.Content = new StringContent(bodyText, System.Text.Encoding.UTF8, contentType);
507 }
508
509 httpResponse = await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
510 }
511
512 StatusCode = (int)httpResponse.StatusCode;
513
514 responseBody = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
515
516 // truncate the raw body for the debug event - this is only to get a sense of the contents when
517 // diagnosing downstream issues, not a full dump. Responses can be tens of MB, and interpolating
518 // the whole thing builds a giant string on every call, so cap it and note the real length.
519 const int maxRawLogChars = 200;
520 string rawForLog = string.IsNullOrEmpty(responseBody) || responseBody.Length <= maxRawLogChars
521 ? responseBody
522 : responseBody.Substring(0, maxRawLogChars) + $"...[truncated, {responseBody.Length} chars total]";
523
524 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"raw result[{route}][{rawForLog}]", this, GPALObjectType.RestHelper);
525
526 string upperMethod = httpMethod.ToUpperInvariant();
527 if (string.IsNullOrEmpty(responseBody) &&
528 (upperMethod == "OPTIONS" || upperMethod == "HEAD"))
529 {
530 var headerDict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
531
532 foreach (var h in httpResponse.Headers)
533 headerDict[h.Key] = string.Join(", ", h.Value);
534
535 foreach (var h in httpResponse.Content.Headers)
536 if (!headerDict.ContainsKey(h.Key))
537 headerDict[h.Key] = string.Join(", ", h.Value);
538
539 responseBody = System.Text.Json.JsonSerializer.Serialize(headerDict,
540 new JsonSerializerOptions { WriteIndented = true });
541 }
542
543 if (false == httpResponse.IsSuccessStatusCode)
544 {
545 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
546 $"Request [{route}] failed with status code: [{StatusCode}] [{httpResponse.ReasonPhrase}]",
547 responseBody, GPALObjectType.None);
548 }
549
550 return responseBody;
551 }
552 catch (Exception ex)
553 {
554 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
555 $"Error in TryOnce for [{httpMethod}] [{route}]", null, GPALObjectType.RestClient, ex);
556 throw;
557 }
558 }
559
560 // === TRY TWICE ===
561 try
562 {
563 return await TryOnce().ConfigureAwait(false);
564 }
565 catch // first timeout, silent retry
566 {
567 try
568 {
569 await Task.Delay(2_000).ConfigureAwait(false);
570 return await TryOnce().ConfigureAwait(false); // second attempt
571 }
572 catch (Exception ex)
573 {
574 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
575 $"Unable to communicate with: [{route}]",
576 null, GPALObjectType.None, ex);
577
578 // this browser's own endpoint, unreachable twice, is the browser being gone, and there is
579 // nothing left for the workflow to decide. a third party service that is down is the
580 // workflow's business, so that still answers with nothing and the workflow chooses
581 if (false == string.IsNullOrEmpty(_browserExtensionApiBase)
582 && true == route.StartsWith(_browserExtensionApiBase, StringComparison.OrdinalIgnoreCase))
583 throw new GPALException($"{GPAL.MyMethodName()}: The browser at [{_browserExtensionApiBase}] did not answer [{route}] on either attempt, so there is nothing to drive");
584
585 return "";
586 }
587 }
588 }
589 public async Task<bool> CheckAPIStatus()
590 {
591 string route = $"{_browserExtensionApiBase}{Endpoints[ApiEndpoint.CheckStatus]}";
592 using (HttpClient client = new HttpClient())
593 {
594 try
595 {
596 HttpResponseMessage response = await client.GetAsync(route).ConfigureAwait(false);
597 string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
598
599 return responseBody.Equals("Server is running");
600 }
601 catch (Exception)
602 {
603 return false;
604 }
605 }
606 }
607 }
608 public static class StringFormatDetector
609 {
610 public static string DetectFormat(string input)
611 {
612 if (string.IsNullOrWhiteSpace(input))
613 return "none";
614
615 // Trim to avoid false negatives from whitespace
616 var trimmed = input.Trim();
617
618 // Check JSON
619 if (IsJson(trimmed))
620 return "json";
621
622 // Check URL encoding
623 if (IsUrlEncoded(trimmed))
624 return "url";
625
626 return "none";
627 }
628
629 internal static bool IsJson(string input)
630 {
631 if ((input.StartsWith("{") && input.EndsWith("}")) ||
632 (input.StartsWith("[") && input.EndsWith("]")))
633 {
634 try
635 {
636 using var doc = JsonDocument.Parse(input);
637 return true;
638 }
639 catch
640 {
641 return false;
642 }
643 }
644 return false;
645 }
646
647 private static bool IsUrlEncoded(string input)
648 {
649 try
650 {
651 var decoded = HttpUtility.UrlDecode(input);
652 var reencoded = HttpUtility.UrlEncode(decoded);
653 return string.Equals(input, reencoded, StringComparison.Ordinal);
654 }
655 catch
656 {
657 return false;
658 }
659 }
660 }
661}