Code/Storage/NetworkStorageCollections.cs

This static class handles network operations for persistent storage collections. It provides methods to save, update, delete, and list records using a configured remote storage service.

Http CallsNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

namespace Sandbox;

public static partial class NetworkStorage
{
	/// <summary>
	/// Save or replace a collection document. Defaults to the current player's Steam ID.
	/// On dedicated servers, a configured secret key is sent automatically so endpoint-only
	/// collections can be edited when the key has collection execute permission.
	/// </summary>
	public static Task<JsonElement?> SaveDocument( string collectionId, string documentId, object data )
		=> SendStorageRequest( "storage-save", "POST", StorageDocumentPath( collectionId, documentId ), data ?? new { }, "replace" );

	/// <summary>Alias for SaveDocument.</summary>
	public static Task<JsonElement?> SetDocument( string collectionId, string documentId, object data )
		=> SaveDocument( collectionId, documentId, data );

	/// <summary>
	/// Apply server-side operations to a collection document. This avoids sending the full
	/// document and lets the backend validate increments, ledgers, and rate limits.
	/// </summary>
	public static Task<JsonElement?> UpdateDocument( string collectionId, string documentId, IEnumerable<NetworkStorageOperation> operations )
	{
		var ops = operations?.Where( op => op is not null ).ToArray() ?? Array.Empty<NetworkStorageOperation>();
		return SendStorageRequest( "storage-update", "POST", StorageDocumentPath( collectionId, documentId ), new { ops }, "ops" );
	}

	/// <summary>Apply server-side operations to a collection document.</summary>
	public static Task<JsonElement?> UpdateDocument( string collectionId, string documentId, params NetworkStorageOperation[] operations )
		=> UpdateDocument( collectionId, documentId, (IEnumerable<NetworkStorageOperation>)operations );

	/// <summary>Alias for UpdateDocument.</summary>
	public static Task<JsonElement?> PatchDocument( string collectionId, string documentId, params NetworkStorageOperation[] operations )
		=> UpdateDocument( collectionId, documentId, operations );

	/// <summary>
	/// Delete a collection document. The backend must have allowRecordDelete enabled for the collection.
	/// Defaults to the current player's Steam ID.
	/// </summary>
	public static Task<JsonElement?> DeleteDocument( string collectionId, string documentId = null )
		=> SendStorageRequest( "storage-delete", "DELETE", StorageDocumentPath( collectionId, documentId ) );

	/// <summary>List save-record metadata for a player in a multi-record collection.</summary>
	public static Task<JsonElement?> ListRecords( string collectionId, string steamId = null )
		=> SendStorageRequest( "storage-records", "GET", StorageRecordIndexPath( collectionId, steamId ) );

	/// <summary>Create a save-record entry for a player in a multi-record collection.</summary>
	public static Task<JsonElement?> CreateRecord( string collectionId, string steamId = null, string recordName = null )
	{
		object body = string.IsNullOrWhiteSpace( recordName ) ? new { } : new { recordName };
		return SendStorageRequest( "storage-record-create", "POST", StorageRecordIndexPath( collectionId, steamId ), body );
	}

	/// <summary>Delete a save-record entry and its stored document data.</summary>
	public static Task<JsonElement?> DeleteRecord( string collectionId, string recordId, string steamId = null )
		=> SendStorageRequest( "storage-record-delete", "DELETE", $"{StorageRecordIndexPath( collectionId, steamId )}/{EscapeRouteSegment( recordId )}" );

	/// <summary>Rename a save-record entry.</summary>
	public static Task<JsonElement?> RenameRecord( string collectionId, string recordId, string recordName, string steamId = null )
		=> SendStorageRequest( "storage-record-rename", "PATCH", $"{StorageRecordIndexPath( collectionId, steamId )}/{EscapeRouteSegment( recordId )}", new { recordName } );

	/// <summary>
	/// Sends one storage request. A write carries an <c>Idempotency-Key</c> generated once for this call, so internal
	/// retries reuse it. <paramref name="writeMode"/> sets <c>x-ns-write-mode</c> ("replace" or "ops") on document writes,
	/// so the server does not have to infer the mode from the body.
	/// </summary>
	private static async Task<JsonElement?> SendStorageRequest( string tag, string method, string path, object body = null, string writeMode = null )
	{
		EnsureConfigured();
		ClearLastEndpointError( tag );
		if ( !await RequireCapabilityAsync( "records", tag ) ) return null;

		string url = null;
		string bodyJson = null;
		try
		{
			var usesDedicatedSecret = TryBuildDedicatedStorageHeaders( out var authHeaders );
			if ( !usesDedicatedSecret )
			{
				if ( TryRejectDedicatedServerPlayerAuth( tag ) )
					return null;
				authHeaders = await BuildAuthHeaders();
			}
			url = BuildUrl( path );

			var headers = new Dictionary<string, string>( authHeaders ?? new Dictionary<string, string>() );
			if ( writeMode is not null ) headers["x-ns-write-mode"] = writeMode;

			Func<HttpContent> createContent = null;
			if ( body is not null )
			{
				bodyJson = JsonSerializer.Serialize( body );
				createContent = () => Http.CreateJsonContent( body );
			}

			if ( NetworkStorageLogConfig.LogRequests )
			{
				var suffix = usesDedicatedSecret ? " secret-key-header" : "";
				NetLog.Request( tag, $"{method}{suffix} {path}" );
				Log.Info( $"[NetworkStorage] {tag} request: {method} {ApiRoot}{path}{suffix}" );
			}

			var reply = await SendRawContentAsync( url, method, createContent, headers );
			if ( NetworkStorageLogConfig.LogResponses )
				Log.Info( $"[NetworkStorage] {tag} → HTTP {reply.Status} {TruncateJson( reply.Body ?? reply.TransportError ?? "", 300 )}" );
			if ( !reply.Ok )
			{
				RecordReplyFailure( tag, reply, "REQUEST_FAILED" );
				return null;
			}

			var parsed = ParseResponse( tag, reply.Body );
			if ( parsed.HasValue && NetworkStorageLogConfig.LogResponses )
				NetLog.Response( tag, TruncateJson( parsed.Value ) );
			return parsed;
		}
		catch ( Exception ex )
		{
			if ( NetworkStorageLogConfig.LogErrors )
			{
				Log.Warning( $"[NetworkStorage] {tag} FAILED — {ex.Message}" );
				Log.Warning( $"[NetworkStorage]   URL: {url ?? $"{ApiRoot}{path}"}" );
				Log.Warning( $"[NetworkStorage]   Method: {method}" );
				if ( bodyJson != null )
					Log.Warning( $"[NetworkStorage]   Body: {bodyJson}" );
				NetLog.Error( tag, ex.Message );
			}
			RecordEndpointError( tag, "REQUEST_FAILED", ex.Message );
			return null;
		}
	}

	private static string StorageDocumentPath( string collectionId, string documentId )
		=> $"/storage/{EscapeRouteSegment( ProjectId )}/{EscapeRouteSegment( collectionId )}/{EscapeRouteSegment( ResolveStorageDocumentId( documentId ) )}";

	private static string StorageRecordIndexPath( string collectionId, string steamId )
		=> $"/storage/{EscapeRouteSegment( ProjectId )}/{EscapeRouteSegment( collectionId )}/{EscapeRouteSegment( ResolveStorageDocumentId( steamId ) )}/records";

	private static string ResolveStorageDocumentId( string documentId )
		=> string.IsNullOrWhiteSpace( documentId ) ? Game.SteamId.ToString() : documentId;

	private static string EscapeRouteSegment( string value )
		=> Uri.EscapeDataString( value ?? "" );
}