Design a bounded Znode 10 Custom API endpoint
Build a small preference read with authenticated server context, ownership-aware caching, bounded output, and an adapter registered through the selected Custom API SDK.
In this guide
Choose a narrow contract
A small preference endpoint still needs a precise ownership boundary. The caller supplies no subject, account, or Store identifier to this service. A request-scoped adapter resolves those values from the authenticated server context, and the authorization service verifies the actor can read the selected buying context.
The cache key includes every ownership dimension. Authorization runs before every cache lookup so a cached result cannot bypass a revoked permission. A null cache entry is a miss; a response with Label set to null represents an absent preference. Labels are bounded to 160 characters and treated as plain text by the UI.
This helper caches a non-sensitive display preference for one minute. The cache adapter serializes the typed key without delimiter collisions and honors the TTL. Evict the exact key after a preference edit, and increment SchemaVersion when the cached contract changes. Extend the key if locale or another input changes the stored value.
PreferenceService.cs csharp
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Gcg.Examples.Preferences;
public sealed record RequestIdentity(
bool IsAuthenticated, string SubjectId, Guid TenantId, Guid AccountId, Guid StoreId);
public sealed record PreferenceKey(Guid TenantId, Guid AccountId, Guid StoreId, string SubjectId, int SchemaVersion);
public sealed record PreferenceResponse(string? Label);
public interface IRequestContext { RequestIdentity Current { get; } }
public interface IPreferenceAuthorization
{
Task DemandReadAsync(RequestIdentity identity, CancellationToken ct);
}
public interface IPreferenceStore
{
Task<string?> FindLabelAsync(PreferenceKey key, CancellationToken ct);
}
public interface IPreferenceCache
{
// Null means cache miss; a response with Label=null means a cached absent preference.
Task<PreferenceResponse?> GetAsync(PreferenceKey key, CancellationToken ct);
Task SetAsync(PreferenceKey key, PreferenceResponse value, TimeSpan ttl, CancellationToken ct);
}
public sealed class PreferenceService
{
private readonly IRequestContext context;
private readonly IPreferenceAuthorization authorization;
private readonly IPreferenceStore store;
private readonly IPreferenceCache cache;
public PreferenceService(IRequestContext context, IPreferenceAuthorization authorization,
IPreferenceStore store, IPreferenceCache cache)
{
this.context = context ?? throw new ArgumentNullException(nameof(context));
this.authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
}
public async Task<PreferenceResponse> GetAsync(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var identity = context.Current;
if (identity is null || !identity.IsAuthenticated || string.IsNullOrWhiteSpace(identity.SubjectId) ||
identity.SubjectId.Length > 200 || identity.TenantId == Guid.Empty ||
identity.AccountId == Guid.Empty || identity.StoreId == Guid.Empty)
throw new UnauthorizedAccessException("An authenticated buying context is required.");
// Membership and read permission are checked before every cache lookup.
await authorization.DemandReadAsync(identity, ct);
var key = new PreferenceKey(identity.TenantId, identity.AccountId,
identity.StoreId, identity.SubjectId, SchemaVersion: 1);
var cached = await cache.GetAsync(key, ct);
if (cached is not null) return Validate(cached);
var response = Validate(new PreferenceResponse(await store.FindLabelAsync(key, ct)));
ct.ThrowIfCancellationRequested();
await cache.SetAsync(key, response, TimeSpan.FromMinutes(1), ct);
return response;
}
private static PreferenceResponse Validate(PreferenceResponse response)
{
if (response.Label is not null && response.Label.Length > 160)
throw new InvalidDataException("Preference label exceeds 160 characters.");
return response;
}
}
Connect the supported Custom API components
Znode's API Customization Guide separates controllers and services in Custom.API.Core from startup and gateway configuration in Engine.Custom.API. Use the selected SDK's controller and authentication conventions to call this application service. Keep IRequestContext request-scoped and construct it from authenticated middleware rather than deserializing it from request JSON.
Register the service and its adapters through the supported dependency-registration path. Map authorization failures to the SDK's authentication or forbidden response, preserve request cancellation, and return the small response DTO. Use the documented customapi route key and the installed gateway configuration when exposing the endpoint. Access Znode-owned data through supported API clients.
Test the boundary
Cover a valid preference, absent value, cache hit, expired entry, oversized label, empty subject, unauthenticated request, and revoked permission. Change each key dimension independently to confirm cache isolation. Verify cancellation reaches the store and cache adapters.
Exercise the deployed gateway route with authorized and unauthorized sessions, then switch the buying account and Store. Confirm that the server resolves the new context and that neither a query string nor a forged request header can select another subject's cached value.
References and further reading
Bring your next engineering question.
Need an API boundary designed for your integration?
Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.