Editor/SyncToolApi.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
/// <summary>
/// HTTP client for the Network Storage v3 management API.
/// Used by the SyncTool editor window to push/pull game data.
/// All requests are authenticated with the secret key from .env.
/// </summary>
public static class SyncToolApi
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds( 30 ) };
private static readonly ConcurrentDictionary<string, string> _lastErrorMessagesByPath = new( StringComparer.OrdinalIgnoreCase );
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _lastResourceErrorMessagesByPath = new( StringComparer.OrdinalIgnoreCase );
private static readonly JsonSerializerOptions _readOptions = new()
{
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
/// <summary>
/// Last error code from the server (e.g., "KEY_UPGRADE_REQUIRED", "FORBIDDEN").
/// Reset on each request. Null if the last request succeeded or had no structured error.
/// </summary>
public static string LastErrorCode { get; private set; }
/// <summary>
/// Last error message from the server.
/// </summary>
public static string LastErrorMessage { get; private set; }
/// <summary>
/// Last error message for a specific management API path.
/// Push All runs resource groups in parallel, so the global message can be
/// cleared by a sibling request before the UI reads it.
/// </summary>
public static string GetLastErrorMessage( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
return null;
return _lastErrorMessagesByPath.TryGetValue( path, out var message ) ? message : null;
}
/// <summary>
/// Last structured error message for a specific resource inside a failed batch request.
/// </summary>
public static string GetLastResourceErrorMessage( string path, string resourceId )
{
if ( string.IsNullOrWhiteSpace( path ) || string.IsNullOrWhiteSpace( resourceId ) )
return null;
return _lastResourceErrorMessagesByPath.TryGetValue( path, out var messages ) &&
messages.TryGetValue( resourceId, out var message )
? message
: null;
}
public static void ReportLocalError( string path, string message, Exception ex = null )
{
if ( string.IsNullOrWhiteSpace( path ) || string.IsNullOrWhiteSpace( message ) )
return;
LastErrorCode = "LOCAL_ERROR";
LastErrorMessage = message;
_lastErrorMessagesByPath[path] = message;
if ( ex != null )
Log.Warning( $"[SyncTool] {path}: {message}\n{ex}" );
else
Log.Warning( $"[SyncTool] {path}: {message}" );
}
/// <summary>
/// Make an authenticated request to the management API.
/// </summary>
public static async Task<JsonElement?> Request( string method, string path, JsonElement? body = null, Dictionary<string, string> extraHeaders = null )
{
ClearErrorStateForPath( path );
LastErrorCode = null;
LastErrorMessage = null;
if ( !SyncToolConfig.IsValid )
{
Log.Warning( "[SyncTool] Config not valid - load .env first" );
return null;
}
var url = $"{SyncToolConfig.BaseUrl}/{SyncToolConfig.ApiVersion}/manage/{SyncToolConfig.ProjectId}/{path}";
var sk = SyncToolConfig.SecretKey ?? "";
var pk = SyncToolConfig.PublicApiKey ?? "";
Log.Info( $"[SyncTool] {method} {path}" );
var request = new HttpRequestMessage( new HttpMethod( method ), url );
request.Headers.Add( "x-api-key", sk );
request.Headers.Add( "x-public-key", pk );
request.Headers.Add( "User-Agent", "SyncTool-sbox/2.0" );
if ( extraHeaders != null )
{
foreach ( var header in extraHeaders )
request.Headers.TryAddWithoutValidation( header.Key, header.Value );
}
if ( body.HasValue )
{
var json = JsonSerializer.Serialize( body.Value );
request.Content = new StringContent( json, Encoding.UTF8, "application/json" );
}
try
{
var response = await _http.SendAsync( request );
var text = await response.Content.ReadAsStringAsync();
if ( !response.IsSuccessStatusCode )
{
Log.Warning( $"[SyncTool] {method} {path}: HTTP {(int)response.StatusCode}" );
try
{
var errJson = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
if ( errJson.TryGetProperty( "error", out var errCode ) )
LastErrorCode = errCode.ValueKind == JsonValueKind.Object && errCode.TryGetProperty( "code", out var codeVal )
? codeVal.GetString()
: errCode.ValueKind == JsonValueKind.String ? errCode.GetString() : null;
if ( errJson.TryGetProperty( "message", out var errMsg ) )
LastErrorMessage = errMsg.GetString();
CaptureStructuredErrors( path, errJson );
LogStructuredErrors( path );
if ( string.IsNullOrWhiteSpace( LastErrorMessage ) )
{
LastErrorMessage = BuildStructuredErrorSummary( path, errJson )
?? BuildStructuredErrorMessageFromBody( text, (int)response.StatusCode );
}
if ( !string.IsNullOrWhiteSpace( LastErrorMessage ) )
_lastErrorMessagesByPath[path] = LastErrorMessage;
else
_lastErrorMessagesByPath[path] = $"HTTP {(int)response.StatusCode}";
// Skip error window for 404/405 only for Sync endpoint fallback behavior.
// Other 404/405 responses are meaningful and should remain visible.
var statusCode = (int)response.StatusCode;
var suppressWindow = string.Equals( path, "sync", StringComparison.OrdinalIgnoreCase )
&& (statusCode == 404 || statusCode == 405);
if ( ShouldShowErrorWindow() && !suppressWindow && (!string.IsNullOrEmpty( LastErrorCode ) || !string.IsNullOrEmpty( LastErrorMessage )) )
EndpointErrorWindow.Show( path, errJson );
if ( LastErrorCode == "KEY_UPGRADE_REQUIRED" )
{
Log.Warning( "[SyncTool] Your secret key uses an old format. Generate a new one at sbox.cool." );
}
else if ( LastErrorCode == "FORBIDDEN" )
{
Log.Warning( $"[SyncTool] Permission denied: {LastErrorMessage}" );
}
else if ( LastErrorCode == "COLLECTION_DELETE_WEBSITE_ONLY" )
{
Log.Warning( "[SyncTool] Collection definitions can only be deleted from the website dashboard after verification. Row/document deletion through the runtime API is still supported." );
}
}
catch
{
LastErrorMessage = BuildStructuredErrorMessageFromBody( text, (int)response.StatusCode );
_lastErrorMessagesByPath[path] = LastErrorMessage;
Log.Warning( $"[SyncTool] -> {LastErrorMessage}" );
if ( ShouldShowErrorWindow() )
MessageDialog.Show( $"Sync Error: {path}", LastErrorMessage, text );
}
return null;
}
var result = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
LogStructuredWarnings( path, result );
// Check for validation errors in successful HTTP responses (ok: false)
if ( result.TryGetProperty( "ok", out var okProp ) && okProp.ValueKind == JsonValueKind.False )
{
if ( result.TryGetProperty( "error", out var errCode ) )
LastErrorCode = errCode.ValueKind == JsonValueKind.Object && errCode.TryGetProperty( "code", out var codeVal )
? codeVal.GetString()
: errCode.ValueKind == JsonValueKind.String ? errCode.GetString() : null;
if ( result.TryGetProperty( "message", out var errMsg ) )
LastErrorMessage = errMsg.GetString();
CaptureStructuredErrors( path, result );
LogStructuredErrors( path );
if ( string.IsNullOrWhiteSpace( LastErrorMessage ) )
LastErrorMessage = BuildStructuredErrorSummary( path, result );
_lastErrorMessagesByPath[path] = LastErrorMessage ?? LastErrorCode ?? "Validation failed";
// Show error window when any structured validation failures are present.
if ( ShouldShowErrorWindow() && ( !string.IsNullOrEmpty( LastErrorCode ) || !string.IsNullOrEmpty( LastErrorMessage ) ) )
EndpointErrorWindow.Show( path, result );
}
return result;
}
catch ( Exception ex )
{
LastErrorMessage = ex.Message;
_lastErrorMessagesByPath[path] = ex.Message;
Log.Warning( $"[SyncTool] {method} {path}: {ex.Message}" );
return null;
}
}
private static Dictionary<string, string> NoCacheHeaders() => new()
{
["Cache-Control"] = "no-cache",
["Pragma"] = "no-cache"
};
private static string WithCacheBust( string path )
{
var separator = path.Contains( "?" ) ? "&" : "?";
return $"{path}{separator}_syncToolTs={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
}
/// <summary>Fetch current server endpoints (includes staged/next-revision endpoints).</summary>
public static Task<JsonElement?> GetEndpoints() => Request( "GET", WithCacheBust( "endpoints?includeStaged=true" ), null, NoCacheHeaders() );
/// <summary>Fetch endpoints for a publish target.</summary>
public static async Task<JsonElement?> GetEndpointsForPublishTarget( string publishTarget )
{
var result = await (string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
? Request( "GET", WithCacheBust( "endpoints?includeStaged=true&revisionTarget=next" ), null, NoCacheHeaders() )
: Request( "GET", WithCacheBust( "endpoints?revisionTarget=live" ), null, NoCacheHeaders() ));
return !result.HasValue || !string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
? result
: FilterPayloadByRevisionTarget( result.Value, "next", "slug" );
}
/// <summary>Push endpoints to server.</summary>
public static Task<JsonElement?> PushEndpoints( JsonElement data ) => Request( "PUT", "endpoints", data );
/// <summary>Push endpoints to server with publish-target support.</summary>
public static Task<JsonElement?> PushEndpoints( JsonElement data, string publishTarget )
{
var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
return Request( "PUT", "endpoints", data, headers );
}
/// <summary>Upsert a single endpoint. Server handles merge.</summary>
public static Task<JsonElement?> PatchEndpoint( JsonElement data, string publishTarget = "live" )
{
var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
return Request( "PATCH", "endpoints", data, headers );
}
/// <summary>Upsert a single collection. Server handles merge.</summary>
public static Task<JsonElement?> PatchCollection( JsonElement data, string publishTarget = "live" )
{
var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
return Request( "PATCH", "collections", data, headers );
}
/// <summary>Upsert a single workflow. Server handles merge.</summary>
public static Task<JsonElement?> PatchWorkflow( JsonElement data )
{
return Request( "PATCH", "workflows", data );
}
/// <summary>Push endpoints to server asynchronously. Returns { jobId } immediately, processes in background.</summary>
public static Task<JsonElement?> PushEndpointsAsync( JsonElement data, string publishTarget = "live" )
{
var headers = new Dictionary<string, string>
{
["x-ns-publish-target"] = publishTarget ?? "live"
};
return Request( "PUT", "endpoints?async=true", data, headers );
}
/// <summary>Poll async sync job status.</summary>
public static Task<JsonElement?> GetSyncJobStatus( string jobId )
{
return Request( "GET", $"sync-jobs/{jobId}" );
}
/// <summary>Fetch current server collections (includes staged/next-revision collections).</summary>
public static Task<JsonElement?> GetCollections() => Request( "GET", WithCacheBust( "collections?includeStaged=true" ), null, NoCacheHeaders() );
/// <summary>Fetch collections for a publish target.</summary>
public static async Task<JsonElement?> GetCollectionsForPublishTarget( string publishTarget )
{
var result = await (string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
? Request( "GET", WithCacheBust( "collections?includeStaged=true&revisionTarget=next" ), null, NoCacheHeaders() )
: Request( "GET", WithCacheBust( "collections?revisionTarget=live" ), null, NoCacheHeaders() ));
return !result.HasValue || !string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase )
? result
: FilterPayloadByRevisionTarget( result.Value, "next", "name" );
}
/// <summary>Push collection schemas to server.</summary>
public static Task<JsonElement?> PushCollections( JsonElement data ) => Request( "PUT", "collections", data );
/// <summary>Push collection schemas to server with publish-target support.</summary>
public static Task<JsonElement?> PushCollections( JsonElement data, string publishTarget )
{
var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
return Request( "PUT", "collections", data, headers );
}
/// <summary>Fetch current server workflows.</summary>
public static Task<JsonElement?> GetWorkflows() => Request( "GET", WithCacheBust( "workflows" ), null, NoCacheHeaders() );
/// <summary>Push workflows to server.</summary>
public static Task<JsonElement?> PushWorkflows( JsonElement data ) => Request( "PUT", "workflows", data );
/// <summary>Fetch read-only project settings used by the editor/runtime config.</summary>
public static Task<JsonElement?> GetProjectSettings() => Request( "GET", "settings" );
/// <summary>Fetch current server tests.</summary>
public static Task<JsonElement?> GetTests() => Request( "GET", "tests" );
/// <summary>Push tests to server.</summary>
public static Task<JsonElement?> PushTests( JsonElement data ) => Request( "PUT", "tests", data );
/// <summary>Ask backend compiler to canonicalize and safely upgrade one source file.</summary>
public static Task<JsonElement?> UpgradeSource( JsonElement data ) => Request( "POST", "source-upgrade", data );
private static Dictionary<string, string> PublishTargetHeaders( string publishTarget )
{
var normalized = string.Equals( publishTarget, "next", StringComparison.OrdinalIgnoreCase ) ? "next" : "live";
return new Dictionary<string, string> { ["x-ns-publish-target"] = normalized };
}
/// <summary>Run a single test via dry-run.</summary>
public static Task<JsonElement?> RunTest( JsonElement data, string publishTarget = "live" ) => Request( "POST", "test-endpoint", data, PublishTargetHeaders( publishTarget ) );
/// <summary>Run all tests via dry-run.</summary>
public static Task<JsonElement?> RunAllTests( JsonElement data, string publishTarget = "live" ) => Request( "POST", "run-tests", data, PublishTargetHeaders( publishTarget ) );
/// <summary>Suggest tests for an endpoint.</summary>
public static Task<JsonElement?> SuggestTests( JsonElement data ) => Request( "POST", "suggest-tests", data );
/// <summary>Auto-test one or all endpoints (no saved tests needed). Pass { slug } for one, {} for all.</summary>
public static Task<JsonElement?> AutoTest( JsonElement data, string publishTarget = "live" ) => Request( "POST", "auto-test", data, PublishTargetHeaders( publishTarget ) );
/// <summary>
/// Push all resources (endpoints, collections, workflows) in a single batch request.
/// Server processes synchronously and returns combined results.
/// </summary>
public static Task<JsonElement?> PushSync( JsonElement data, string publishTarget = "live" )
{
var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
return Request( "PUT", "sync", data, headers );
}
/// <summary>Validate resources before pushing. Does not write server-side data.</summary>
public static Task<JsonElement?> PreflightSync( JsonElement data, string publishTarget = "live" )
{
var headers = publishTarget != "live" ? new Dictionary<string, string> { ["x-ns-publish-target"] = publishTarget } : null;
return Request( "POST", "sync/preflight", data, headers );
}
/// <summary>Sync package/revision info with backend.</summary>
public static Task<JsonElement?> SyncPackageInfo( JsonElement data ) => Request( "POST", "package-sync", data );
/// <summary>Fetch stored game-package/revision state from backend.</summary>
public static Task<JsonElement?> GetGamePackage() => Request( "GET", "game-package" );
/// <summary>
/// Validate credentials against the server.
/// Sends secret key via x-api-key header and optionally public key via x-public-key header.
/// Returns { ok, project, checks, permissions? }
/// </summary>
public static async Task<JsonElement?> Validate( string publicKey = null )
{
LastErrorCode = null;
LastErrorMessage = null;
if ( !SyncToolConfig.IsValid )
{
Log.Warning( "[SyncTool] Validate: config not valid" );
return null;
}
var url = $"{SyncToolConfig.BaseUrl}/{SyncToolConfig.ApiVersion}/manage/{SyncToolConfig.ProjectId}/validate";
var sk = SyncToolConfig.SecretKey ?? "";
var pk = publicKey ?? "";
Log.Info( $"[SyncTool] Validating credentials..." );
var request = new HttpRequestMessage( HttpMethod.Get, url );
request.Headers.Add( "x-api-key", sk );
request.Headers.Add( "User-Agent", "SyncTool-sbox/2.0" );
if ( !string.IsNullOrEmpty( publicKey ) )
request.Headers.Add( "x-public-key", publicKey );
try
{
var response = await _http.SendAsync( request );
var text = await response.Content.ReadAsStringAsync();
if ( !response.IsSuccessStatusCode )
{
LastErrorCode = $"HTTP_{(int)response.StatusCode}";
LastErrorMessage = $"Server returned HTTP {(int)response.StatusCode}";
Log.Warning( $"[SyncTool] Validate failed: HTTP {(int)response.StatusCode}" );
return null;
}
var result = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
if ( result.TryGetProperty( "error", out var errCode ) )
{
LastErrorCode = errCode.ValueKind == JsonValueKind.Object && errCode.TryGetProperty( "code", out var codeVal )
? codeVal.GetString()
: errCode.ValueKind == JsonValueKind.String ? errCode.GetString() : null;
if ( result.TryGetProperty( "message", out var errMsg ) )
LastErrorMessage = errMsg.GetString();
Log.Warning( $"[SyncTool] Validate error: {LastErrorCode} - {LastErrorMessage}" );
}
return result;
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Validate: {ex.Message}" );
return null;
}
}
private static bool ShouldShowErrorWindow()
{
return SyncToolWindow.IsWindowOpen;
}
private static void ClearErrorStateForPath( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
return;
_lastErrorMessagesByPath.TryRemove( path, out _ );
_lastResourceErrorMessagesByPath.TryRemove( path, out _ );
if ( string.Equals( path, "sync", StringComparison.OrdinalIgnoreCase ) )
{
_lastErrorMessagesByPath.TryRemove( "endpoints", out _ );
_lastErrorMessagesByPath.TryRemove( "collections", out _ );
_lastErrorMessagesByPath.TryRemove( "workflows", out _ );
_lastResourceErrorMessagesByPath.TryRemove( "endpoints", out _ );
_lastResourceErrorMessagesByPath.TryRemove( "collections", out _ );
_lastResourceErrorMessagesByPath.TryRemove( "workflows", out _ );
}
}
private static string BuildStructuredErrorMessageFromBody( string body, int statusCode )
{
if ( statusCode == 404 || statusCode == 405 )
return $"Backend route missing or unavailable (HTTP {statusCode}). The management API route may not be deployed on this backend.";
var detail = TruncateForLog( body, 500 );
return string.IsNullOrWhiteSpace( detail )
? $"HTTP {statusCode}"
: $"HTTP {statusCode}: {detail}";
}
private static void CaptureStructuredErrors( string path, JsonElement payload )
{
var entries = CollectStructuredResultEntries( path, payload );
if ( entries.Count == 0 )
return;
var messagesByPath = new Dictionary<string, ConcurrentDictionary<string, string>>( StringComparer.OrdinalIgnoreCase );
foreach ( var entry in entries )
{
var item = entry.Item;
if ( item.ValueKind != JsonValueKind.Object )
continue;
var itemOk = item.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.True;
if ( itemOk )
continue;
var resourceId = GetStringProperty( item, "resourceId", "slug", "name", "id" );
if ( string.IsNullOrWhiteSpace( resourceId ) )
continue;
var detail = BuildStructuredErrorDetail( item );
if ( string.IsNullOrWhiteSpace( detail ) )
continue;
if ( !messagesByPath.TryGetValue( entry.Path, out var messages ) )
{
messages = new ConcurrentDictionary<string, string>( StringComparer.OrdinalIgnoreCase );
messagesByPath[entry.Path] = messages;
}
messages[resourceId] = detail;
}
foreach ( var pair in messagesByPath )
_lastResourceErrorMessagesByPath[pair.Key] = pair.Value;
}
private static void LogStructuredErrors( string path )
{
if ( !_lastResourceErrorMessagesByPath.TryGetValue( path, out var messages ) )
return;
foreach ( var pair in messages )
Log.Warning( $"[SyncTool] {path}:{pair.Key} -> {pair.Value}" );
}
private static string BuildStructuredErrorSummary( string path, JsonElement payload )
{
var entries = CollectStructuredResultEntries( path, payload );
foreach ( var entry in entries )
{
if ( entry.Item.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.True )
continue;
var resourceId = GetStringProperty( entry.Item, "resourceId", "slug", "name", "id" );
var detail = BuildStructuredErrorDetail( entry.Item );
if ( string.IsNullOrWhiteSpace( detail ) )
continue;
return string.IsNullOrWhiteSpace( resourceId )
? $"{entry.Path}: {detail}"
: $"{entry.Path}:{resourceId} {detail}";
}
var message = GetStringProperty( payload, "message", "error" );
return string.IsNullOrWhiteSpace( message ) ? null : message;
}
private static JsonElement? FilterPayloadByRevisionTarget( JsonElement payload, string targetRevision, params string[] idKeys )
{
if ( !payload.TryGetProperty( "data", out var data ) || data.ValueKind != JsonValueKind.Array )
return payload;
var targetItemsById = new Dictionary<string, JsonElement>( StringComparer.OrdinalIgnoreCase );
var hasTargetItems = false;
foreach ( var item in data.EnumerateArray() )
{
if ( item.ValueKind != JsonValueKind.Object )
continue;
var resourceId = GetStringProperty( item, idKeys );
if ( string.IsNullOrWhiteSpace( resourceId ) )
continue;
if ( item.TryGetProperty( "revisionTarget", out var rev )
&& rev.ValueKind == JsonValueKind.String
&& string.Equals( rev.GetString(), targetRevision, StringComparison.OrdinalIgnoreCase ) )
{
targetItemsById[resourceId] = item;
hasTargetItems = true;
}
}
if ( !hasTargetItems )
{
var deduped = new List<JsonElement>( data.GetArrayLength() );
var seenNoTarget = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
var hasDuplicates = false;
foreach ( var item in data.EnumerateArray() )
{
if ( item.ValueKind != JsonValueKind.Object )
continue;
var resourceId = GetStringProperty( item, idKeys );
if ( string.IsNullOrWhiteSpace( resourceId ) || !seenNoTarget.Add( resourceId ) )
{
hasDuplicates = hasDuplicates || !string.IsNullOrWhiteSpace( resourceId );
continue;
}
deduped.Add( item );
}
if ( hasDuplicates )
{
var payloadObjNoTarget = JsonSerializer.Deserialize<Dictionary<string, object>>( payload.GetRawText(), _readOptions );
if ( payloadObjNoTarget != null )
{
payloadObjNoTarget["data"] = deduped;
return JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payloadObjNoTarget ) );
}
}
}
var selected = new List<JsonElement>( data.GetArrayLength() );
var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var item in data.EnumerateArray() )
{
if ( item.ValueKind != JsonValueKind.Object )
continue;
var resourceId = GetStringProperty( item, idKeys );
if ( string.IsNullOrWhiteSpace( resourceId ) || seen.Contains( resourceId ) )
continue;
if ( targetItemsById.TryGetValue( resourceId, out var targetItem ) )
selected.Add( targetItem );
else
selected.Add( item );
seen.Add( resourceId );
}
var payloadObj = JsonSerializer.Deserialize<Dictionary<string, object>>( payload.GetRawText(), _readOptions );
if ( payloadObj == null )
return payload;
payloadObj["data"] = selected;
return JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payloadObj ) );
}
private static void LogStructuredWarnings( string path, JsonElement payload )
{
var entries = CollectStructuredResultEntries( path, payload );
foreach ( var entry in entries )
{
if ( entry.Item.ValueKind != JsonValueKind.Object )
continue;
if ( entry.Item.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.False )
continue;
var resourceId = GetStringProperty( entry.Item, "resourceId", "slug", "name", "id" ) ?? "(unknown)";
if ( !entry.Item.TryGetProperty( "diagnostics", out var diagnostics ) || diagnostics.ValueKind != JsonValueKind.Array )
continue;
foreach ( var diagnostic in diagnostics.EnumerateArray() )
{
var severity = GetStringProperty( diagnostic, "severity" );
if ( string.Equals( severity, "error", StringComparison.OrdinalIgnoreCase ) )
continue;
var formatted = FormatDiagnostic( diagnostic );
if ( string.IsNullOrWhiteSpace( formatted ) )
continue;
// Only log warnings, skip info-level messages
if ( !string.Equals( severity, "info", StringComparison.OrdinalIgnoreCase ) )
Log.Warning( $"[SyncTool] {entry.Path}:{resourceId} {severity} -> {formatted}" );
}
}
}
private static List<(string Path, JsonElement Item)> CollectStructuredResultEntries( string path, JsonElement payload )
{
var entries = new List<(string, JsonElement)>();
var topLevel = GetStructuredResourceArray( payload );
if ( topLevel.HasValue )
{
foreach ( var item in topLevel.Value.EnumerateArray() )
entries.Add( ( path, item ) );
}
if ( !string.Equals( path, "sync", StringComparison.OrdinalIgnoreCase ) )
return entries;
foreach ( var section in new[] { "endpoints", "collections", "workflows" } )
{
if ( !payload.TryGetProperty( section, out var sectionValue ) || sectionValue.ValueKind != JsonValueKind.Object )
continue;
if ( !sectionValue.TryGetProperty( "results", out var sectionResults ) || sectionResults.ValueKind != JsonValueKind.Array )
continue;
foreach ( var item in sectionResults.EnumerateArray() )
entries.Add( ( section, item ) );
}
return entries;
}
private static JsonElement? GetStructuredResourceArray( JsonElement payload )
{
foreach ( var key in new[] { "results", "resources", "items" } )
{
if ( payload.TryGetProperty( key, out var value ) && value.ValueKind == JsonValueKind.Array )
return value;
}
return null;
}
private static string BuildStructuredErrorDetail( JsonElement item )
{
var parts = new List<string>();
var message = GetStringProperty( item, "message" );
var error = GetStringProperty( item, "error" );
if ( !string.IsNullOrWhiteSpace( message ) &&
!string.Equals( message, "One or more endpoints failed validation.", StringComparison.OrdinalIgnoreCase ) &&
!string.Equals( message, "One or more collections failed validation.", StringComparison.OrdinalIgnoreCase ) &&
!string.Equals( message, "One or more workflows failed validation.", StringComparison.OrdinalIgnoreCase ) )
{
parts.Add( message );
}
else if ( !string.IsNullOrWhiteSpace( error ) && !string.Equals( error, "VALIDATION_FAILED", StringComparison.OrdinalIgnoreCase ) )
{
parts.Add( error );
}
if ( item.TryGetProperty( "diagnostics", out var diagnostics ) && diagnostics.ValueKind == JsonValueKind.Array )
{
foreach ( var diagnostic in diagnostics.EnumerateArray() )
{
var formatted = FormatDiagnostic( diagnostic );
if ( !string.IsNullOrWhiteSpace( formatted ) )
parts.Add( formatted );
}
}
if ( parts.Count == 0 && item.TryGetProperty( "errors", out var errors ) && errors.ValueKind == JsonValueKind.Array )
{
foreach ( var errorItem in errors.EnumerateArray() )
{
var formatted = FormatDiagnostic( errorItem );
if ( !string.IsNullOrWhiteSpace( formatted ) )
parts.Add( formatted );
}
}
return parts.Count > 0 ? string.Join( " | ", parts ) : GetStringProperty( item, "message", "error" );
}
private static string FormatDiagnostic( JsonElement diagnostic )
{
if ( diagnostic.ValueKind == JsonValueKind.String )
return diagnostic.GetString();
if ( diagnostic.ValueKind != JsonValueKind.Object )
return diagnostic.ToString();
var code = GetStringProperty( diagnostic, "code", "type", "budgetCategory" );
var message = GetStringProperty( diagnostic, "message", "detail", "reason", "error" );
var head = !string.IsNullOrWhiteSpace( code ) && !string.IsNullOrWhiteSpace( message )
? $"{code}: {message}"
: !string.IsNullOrWhiteSpace( message ) ? message : code;
var context = new List<string>();
var sourcePath = GetStringProperty( diagnostic, "sourcePath", "path", "file" );
var sourcePointer = GetStringProperty( diagnostic, "sourcePointer" );
if ( !string.IsNullOrWhiteSpace( sourcePath ) )
{
var line = GetIntProperty( diagnostic, "line", "sourceLine" );
var column = GetIntProperty( diagnostic, "column", "sourceColumn" );
var location = sourcePath;
if ( line.HasValue )
location += column.HasValue ? $":{line}:{column}" : $":{line}";
context.Add( location );
}
else if ( !string.IsNullOrWhiteSpace( sourcePointer ) )
{
context.Add( sourcePointer );
}
var nodeId = GetStringProperty( diagnostic, "nodeId", "canonicalNode", "stepId" );
if ( !string.IsNullOrWhiteSpace( nodeId ) )
context.Add( $"node={nodeId}" );
var suggestion = GetStringProperty( diagnostic, "suggestedFix", "suggestion", "fix" );
if ( !string.IsNullOrWhiteSpace( suggestion ) )
context.Add( $"fix={suggestion}" );
if ( string.IsNullOrWhiteSpace( head ) )
head = diagnostic.ToString();
return context.Count > 0 ? $"{head} ({string.Join( ", ", context )})" : head;
}
private static string GetStringProperty( JsonElement element, params string[] keys )
{
foreach ( var key in keys )
{
if ( element.TryGetProperty( key, out var value ) && value.ValueKind == JsonValueKind.String )
return value.GetString();
}
return null;
}
private static int? GetIntProperty( JsonElement element, params string[] keys )
{
foreach ( var key in keys )
{
if ( !element.TryGetProperty( key, out var value ) || value.ValueKind != JsonValueKind.Number )
continue;
if ( value.TryGetInt32( out var number ) )
return number;
}
return null;
}
private static string PrettyPrintJson( string text )
{
if ( string.IsNullOrWhiteSpace( text ) )
return text;
try
{
var parsed = JsonSerializer.Deserialize<JsonElement>( text, _readOptions );
return JsonSerializer.Serialize( parsed, new JsonSerializerOptions { WriteIndented = true } );
}
catch
{
return text;
}
}
private static string TruncateForLog( string text, int maxLength )
{
if ( string.IsNullOrEmpty( text ) || text.Length <= maxLength )
return text;
return $"{text[..maxLength]}... (truncated, {text.Length} chars)";
}
}