This static class manages data persistence by interacting with a backend storage API. It provides methods to save, update, delete, and list collection documents and player records.
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 ?? "" );
}