@page "/design/api-methods/create" @page "/design/api-methods/{Id:int}/edit" @using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject IInboundApiRepository InboundApiRepository @inject IInboundApiKeyAdmin ApiKeyAdmin @inject ScriptAnalysis.ScriptAnalysisService AnalysisService @inject NavigationManager NavigationManager @implements IDisposable

@(Id.HasValue ? "Edit API Method" : "Add API Method")

@if (_loading) { } else {
@* The body used to stack six unrelated groups with nothing but a label between them. Fieldset + legend gives each stack a real boundary — same idiom as TransportExport. *@
Method
Every call to this method records its routed site steps as audit rows the Flow Debugger can replay. Adds 7–10 audit rows per call; for low-volume integration methods.
Approved API Keys
@if (_allKeys.Count == 0) {
No API keys configured. Create one to authorize callers for this method.
} else {
@foreach (var key in _allKeys) { var checkboxId = $"approved-key-{key.KeyId}";
}
Callers must present a checked key in the X-API-Key header to invoke this method.
}
Contract
Script
@if (_formError != null) {
@_formError
}
@if (_showTestRun) {
Test Run
@* Lives inside the body, not between header and body, where it rendered as a third band. Wording unchanged — pinned by TestRunWarningTests. *@
Heads up: runs the script as typed (unsaved edits included) against the supplied Parameters. Route calls throw — cross-site routing needs a deployed site reachable over the cluster transport.
@if (_runResult != null) { @_runResult.DurationMs ms }
@if (_runResult != null) { @if (_runResult.Success) {
@_runResult.ReturnValueJson
} else {
@_runResult.Error
@if (_runResult.Markers is { Count: > 0 }) {
    @foreach (var m in _runResult.Markers) {
  • Line @m.StartLineNumber, col @m.StartColumn: @m.Message @m.Code
  • }
}
} @if (!string.IsNullOrEmpty(_runResult.ConsoleOutput)) {
@_runResult.ConsoleOutput
} }
} }
@code { [Parameter] public int? Id { get; set; } private bool _loading = true; private string _name = "", _script = ""; private int _timeoutSeconds = 30; private bool _traceAllCalls; private string? _params, _returns; private string? _formError; private MonacoEditor? _editor; private IReadOnlyList _markers = Array.Empty(); // Inbound-API key re-arch: the approved-keys list is driven by the IInboundApiKeyAdmin // seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.) // still lives on IInboundApiRepository — only the key↔method approval relationship moved to // per-key method-scopes. Keys are identified by an opaque string KeyId. private ApiMethod? _existing; private List _allKeys = new(); private HashSet _selectedKeyIds = new(StringComparer.Ordinal); // Keys approved for this method when the form loaded (empty on create), for diffing on save. private HashSet _initialKeyIds = new(StringComparer.Ordinal); private bool _showTestRun; private bool _running; private Dictionary _paramValues = new(); private ScriptAnalysis.SandboxRunResult? _runResult; private CancellationTokenSource? _runCts; private bool _disposed; protected override async Task OnInitializedAsync() { try { // All keys come from the seam (hash-free projection). _allKeys = (await ApiKeyAdmin.ListAsync()).ToList(); } catch (Exception ex) { _formError = ex.Message; } if (Id.HasValue) { try { _existing = await InboundApiRepository.GetApiMethodByIdAsync(Id.Value); if (_existing != null) { _name = _existing.Name; _script = _existing.Script; _timeoutSeconds = _existing.TimeoutSeconds; _traceAllCalls = _existing.TraceAllCalls; _params = _existing.ParameterDefinitions; _returns = _existing.ReturnDefinition; // Seed approved keys from the seam: which keys' scopes contain this method. var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name); _initialKeyIds = new HashSet(keysForMethod, StringComparer.Ordinal); _selectedKeyIds = new HashSet(_initialKeyIds, StringComparer.Ordinal); } } catch (Exception ex) { _formError = ex.Message; } } _loading = false; } private void ToggleKey(string keyId, bool isChecked) { if (isChecked) _selectedKeyIds.Add(keyId); else _selectedKeyIds.Remove(keyId); } private async Task Save() { _formError = null; if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_script)) { _formError = "Name and script required."; return; } try { // Save the ApiMethod entity FIRST so the method Name exists before we reconcile // key scopes against it. The method entity stays in SQL Server; we leave the // (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5. string methodName; if (_existing != null) { _existing.Script = _script; _existing.TimeoutSeconds = _timeoutSeconds; _existing.TraceAllCalls = _traceAllCalls; _existing.ParameterDefinitions = _params?.Trim(); _existing.ReturnDefinition = _returns?.Trim(); await InboundApiRepository.UpdateApiMethodAsync(_existing); methodName = _existing.Name; } else { var m = new ApiMethod(_name.Trim(), _script) { TimeoutSeconds = _timeoutSeconds, TraceAllCalls = _traceAllCalls, ParameterDefinitions = _params?.Trim(), ReturnDefinition = _returns?.Trim() }; await InboundApiRepository.AddApiMethodAsync(m); methodName = m.Name; } await InboundApiRepository.SaveChangesAsync(); // Reconcile per-key method-scopes for the affected keys (added/removed vs. load time). if (!await ReconcileKeyScopesAsync(methodName)) return; NavigationManager.NavigateTo("/design/external-systems"); } catch (Exception ex) { _formError = ex.Message; } } /// /// Pushes this method's name into / out of each affected key's scope set. Returns false /// (leaving a form error) when a revoke would empty a key's scopes — the server rejects /// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected /// key so we never clobber unrelated method-scopes. /// private async Task ReconcileKeyScopesAsync(string methodName) { var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal) .Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal)) .ToHashSet(StringComparer.Ordinal); // Read each affected key's CURRENT full scope set so add/remove preserves other methods. var currentMethodsByKey = new Dictionary>(StringComparer.Ordinal); foreach (var keyId in affected) { currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId); } var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal); var plan = ApiMethodKeyScopeReconciler.Reconcile( methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById); // Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it). if (plan.EmptyScopeKeyNames.Count > 0) { var names = string.Join(", ", plan.EmptyScopeKeyNames); _formError = $"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " + "Disable or delete the key instead, or grant it another method first."; return false; } try { foreach (var update in plan.Updates) { var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods); if (!ok) throw new InvalidOperationException( $"Key '{NameFor(update.KeyId)}' was not found in the key store."); } } catch (Exception ex) { throw new InvalidOperationException( $"Method '{methodName}' was saved, but updating approved-key scopes failed partway: {ex.Message} " + "Some keys may be partially updated — review them on the API Keys page and retry.", ex); } // Selection is now the baseline (matters if save is retried without reload). _initialKeyIds = new HashSet(_selectedKeyIds, StringComparer.Ordinal); return true; } // Returns the display name for a keyId if available from the loaded key list, else the id itself. private string NameFor(string keyId) => _allKeys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))?.Name ?? keyId; private void GoBack() => NavigationManager.NavigateTo("/design/external-systems"); /// Cancels an in-flight Test Run when the component is torn down. public void Dispose() { _disposed = true; _runCts?.Cancel(); _runCts?.Dispose(); _runCts = null; } private void ToggleTestRunPanel() => _showTestRun = !_showTestRun; private async Task RunInSandboxAsync() { _runCts?.Cancel(); _runCts = new CancellationTokenSource(); _running = true; _runResult = null; StateHasChanged(); try { var jsonParams = _paramValues.ToDictionary( kv => kv.Key, kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value)); var request = new ScriptAnalysis.SandboxRunRequest( _script, jsonParams, TimeoutSeconds: _timeoutSeconds, Kind: ScriptAnalysis.ScriptKind.InboundApi); _runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token); } catch (OperationCanceledException) { /* superseded by next Run click */ } catch (Exception ex) { _runResult = new ScriptAnalysis.SandboxRunResult( Success: false, ReturnValueJson: null, ReturnTypeName: null, ConsoleOutput: "", Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}", ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError, DurationMs: 0, Markers: null); } finally { _running = false; // The run may have been cancelled BY Dispose — the component is gone, don't re-render it. if (!_disposed) StateHasChanged(); } } private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch { ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error", ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation", ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error", ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout", _ => "Error" }; }