diff --git a/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java b/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java index cce3909ff3..72048fb763 100644 --- a/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java +++ b/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.generic_action._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.generic_action.action.cli.cmd.GenericActionAsciidocCommand; import com.fortify.cli.generic_action.action.cli.cmd.GenericActionGetCommand; import com.fortify.cli.generic_action.action.cli.cmd.GenericActionHelpCommand; @@ -24,6 +26,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) @Command( name = "action", resourceBundle = "com.fortify.cli.generic_action.i18n.GenericActionMessages", diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java index 89b2fac434..79a02d5bc8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java @@ -19,9 +19,14 @@ import com.fortify.cli.aviator.ssc.cli.cmd.AviatorSSCCommands; import com.fortify.cli.aviator.token.cli.cmd.AviatorTokenCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) +@RelatedModules({"ssc"}) @Command( name = "aviator", resourceBundle = "com.fortify.cli.aviator.i18n.AviatorMessages", diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java new file mode 100644 index 0000000000..daefd8377c --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +/** + * Enum that holds the type of a module as a product module (SSC, FoD, Aviator, SC-SAST, SC-DAST) + * or a non-product/other module (util, tool, license, actions, config, ...) + * + * @author Sangamesh Vijaykumar + */ + +public enum ModuleType { + PRODUCT, // SSC, FoD, Aviator, SC-SAST, SC-DAST + OTHER // util, tool, license, actions, config, ... +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java new file mode 100644 index 0000000000..78f7c3edf9 --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a module as a product module (SSC, FoD, Aviator, SC-SAST, SC-DAST) + * or a non-product/other module (util, tool, license, actions, config, ...). + * + * @author Sangamesh Vijaykumar + */ +@Retention(RUNTIME) +@Target(ElementType.TYPE) +public @interface ProductModule { + ModuleType value(); +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java new file mode 100644 index 0000000000..e5d636693c --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines which base modules a module is related to. + * Example: @RelatedModules({"ssc","fod"}) on the tool/util module. + * + * @author Sangamesh Vijaykumar + */ + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface RelatedModules { + String[] value(); // base modules this module is related to, e.g. {"ssc","fod"} +} diff --git a/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java b/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java index ec8c458e2d..b4247d7f77 100644 --- a/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java +++ b/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.config._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.config.language.cli.cmd.LanguageCommands; import com.fortify.cli.config.proxy.cli.cmd.ProxyCommands; import com.fortify.cli.config.publickey.cli.cmd.PublicKeyCommands; @@ -20,6 +22,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) @Command( name = "config", aliases = "cfg", diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java index 666231550e..45149230ff 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.fod._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.fod._common.session.cli.cmd.FoDSessionCommands; import com.fortify.cli.fod.access_control.cli.cmd.FoDAccessControlCommands; import com.fortify.cli.fod.action.cli.cmd.FoDActionCommands; @@ -30,6 +32,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) @Command( name = "fod", resourceBundle = "com.fortify.cli.fod.i18n.FoDMessages", diff --git a/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java b/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java index 0b335d51b9..aa301a6493 100644 --- a/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java +++ b/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java @@ -13,11 +13,14 @@ package com.fortify.cli.license._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.license.msp_report.cli.cmd.MspReportCommands; import com.fortify.cli.license.ncd_report.cli.cmd.NcdReportCommands; import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) @Command( name = "license", resourceBundle = "com.fortify.cli.license.i18n.LicenseMessages", diff --git a/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java b/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java index 164b435d8a..f93c1852ee 100644 --- a/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java +++ b/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java @@ -13,6 +13,9 @@ package com.fortify.cli.sc_dast._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.sc_dast.rest.cli.cmd.SCDastRestCommands; import com.fortify.cli.sc_dast.scan.cli.cmd.SCDastScanCommands; import com.fortify.cli.sc_dast.scan_policy.cli.cmd.SCDastScanPolicyCommands; @@ -21,6 +24,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) +@RelatedModules({"ssc"}) @Command( name = "sc-dast", resourceBundle = "com.fortify.cli.sc_dast.i18n.SCDastMessages", diff --git a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java index 97393b2f1f..e980aa9dfe 100644 --- a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java +++ b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java @@ -13,6 +13,9 @@ package com.fortify.cli.sc_sast._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.sc_sast.rest.cli.cmd.SCSastRestCommands; import com.fortify.cli.sc_sast.scan.cli.cmd.SCSastScanCommands; import com.fortify.cli.sc_sast.sensor.cli.cmd.SCSastSensorCommands; @@ -20,6 +23,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) +@RelatedModules({"ssc"}) @Command( name = "sc-sast", resourceBundle = "com.fortify.cli.sc_sast.i18n.SCSastMessages", diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java index cb09778300..5430d9e3eb 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.ssc._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.ssc._common.session.cli.cmd.SSCSessionCommands; import com.fortify.cli.ssc.access_control.cli.cmd.SSCAccessControlCommands; import com.fortify.cli.ssc.action.cli.cmd.SSCActionCommands; @@ -33,6 +35,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) @Command( name = "ssc", resourceBundle = "com.fortify.cli.ssc.i18n.SSCMessages", diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java index ed99ffd473..7959ecf56c 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java @@ -13,6 +13,9 @@ package com.fortify.cli.tool._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.tool.bugtracker_utility.cli.cmd.ToolBugTrackerUtilityCommands; import com.fortify.cli.tool.debricked_cli.cli.cmd.ToolDebrickedCliCommands; import com.fortify.cli.tool.definitions.cli.cmd.ToolDefinitionsCommands; @@ -24,6 +27,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) +@RelatedModules({"ssc","fod"}) @Command( name = "tool", resourceBundle = "com.fortify.cli.tool.i18n.ToolMessages", diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java new file mode 100644 index 0000000000..11ade4fa46 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java @@ -0,0 +1,365 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util._common.helper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.JsonNode; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Cache for fcli record-collecting operations. Provides background loading with + * progressive record access, suitable for both MCP and RPC servers. + * + * Features: + * - LRU cache with configurable size and TTL + * - Background async loading with partial result access + * - Cancel support for long-running collections + * - Thread-safe concurrent access + * - Support for session options through option resolver + * + * @author Ruud Senden + */ +@Slf4j +public class FcliRecordsCache { + private static final long DEFAULT_TTL = 10 * 60 * 1000; // 10 minutes + private static final int DEFAULT_MAX_ENTRIES = 5; + private static final int DEFAULT_BG_THREADS = 2; + + private final long ttl; + private final int maxEntries; + private final Map cache; + private final Map inProgress = new ConcurrentHashMap<>(); + private final ExecutorService backgroundExecutor; + private Function> optionResolver; + + public FcliRecordsCache() { + this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL, DEFAULT_BG_THREADS); + } + + public FcliRecordsCache(int maxEntries, long ttlMillis, int bgThreads) { + this.ttl = ttlMillis; + this.maxEntries = maxEntries; + // Use access-ordered LinkedHashMap for LRU behavior + this.cache = new LinkedHashMap<>(maxEntries, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxEntries; + } + }; + this.backgroundExecutor = Executors.newFixedThreadPool(bgThreads, r -> { + var t = new Thread(r, "fcli-cache-loader"); + t.setDaemon(true); + return t; + }); + log.info("Initialized FcliRecordsCache: maxEntries={} ttl={}ms bgThreads={}", maxEntries, ttlMillis, bgThreads); + } + + /** + * Set a function to resolve default options for commands (e.g., session options). + */ + public void setOptionResolver(Function> resolver) { + this.optionResolver = resolver; + } + + /** + * Get cached result, or start background collection if not cached. + * Returns null if result is already cached (caller should use getCached). + * Returns InProgressEntry if background collection started/exists. + */ + public InProgressEntry getOrStartBackground(String cacheKey, boolean refresh, String command) { + var cached = getCached(cacheKey); + if (!refresh && cached != null) { + return null; // Already cached + } + + var existing = inProgress.get(cacheKey); + if (existing != null && !existing.isExpired(ttl)) { + return existing; // Already loading + } + + return startNewBackgroundCollection(cacheKey, command); + } + + /** + * Start a background collection and return immediately with the cacheKey. + */ + public String startBackgroundCollection(String command) { + var cacheKey = UUID.randomUUID().toString(); + startNewBackgroundCollection(cacheKey, command); + return cacheKey; + } + + private InProgressEntry startNewBackgroundCollection(String cacheKey, String command) { + var entry = new InProgressEntry(cacheKey, command); + inProgress.put(cacheKey, entry); + + var future = buildCollectionFuture(entry, command); + future.whenComplete(createCompletionHandler(entry, cacheKey)); + + entry.setFuture(future); + log.debug("Started background collection: cacheKey={} command={}", cacheKey, command); + + return entry; + } + + private CompletableFuture buildCollectionFuture(InProgressEntry entry, String command) { + // Resolve options before starting async execution + var defaultOptions = optionResolver != null ? optionResolver.apply(command) : null; + + return CompletableFuture.supplyAsync(() -> { + var records = entry.getRecords(); + var result = FcliRunnerHelper.collectRecords(command, record -> { + if (!Thread.currentThread().isInterrupted()) { + records.add(record); + } + }, defaultOptions); + + if (Thread.currentThread().isInterrupted()) { + return null; + } + + var fullResult = FcliToolResult.fromRecords(result, records); + if (result.getExitCode() == 0) { + put(entry.getCacheKey(), fullResult); + } + return fullResult; + }, backgroundExecutor); + } + + private BiConsumer createCompletionHandler(InProgressEntry entry, String cacheKey) { + return (result, throwable) -> { + entry.setCompleted(true); + captureExecutionResult(entry, result, throwable); + cleanupFailedCollection(entry, cacheKey); + log.debug("Background collection completed: cacheKey={} exitCode={}", cacheKey, entry.getExitCode()); + }; + } + + private void captureExecutionResult(InProgressEntry entry, FcliToolResult result, Throwable throwable) { + if (throwable != null) { + entry.setExitCode(999); + entry.setStderr(throwable.getMessage() != null ? throwable.getMessage() : "Background collection failed"); + } else if (result != null) { + entry.setExitCode(result.getExitCode()); + entry.setStderr(result.getStderr()); + } else { + entry.setExitCode(999); + entry.setStderr("Cancelled"); + } + } + + private void cleanupFailedCollection(InProgressEntry entry, String cacheKey) { + if (entry.getExitCode() != 0) { + inProgress.remove(cacheKey); + } + } + + /** + * Store a result in the cache. + */ + public void put(String cacheKey, FcliToolResult result) { + if (result == null) { + return; + } + synchronized (cache) { + cache.put(cacheKey, new CacheEntry(result)); + } + log.debug("Cached result: cacheKey={} records={}", cacheKey, result.getRecords() != null ? result.getRecords().size() : 0); + } + + /** + * Get a cached result if present and not expired. + */ + public FcliToolResult getCached(String cacheKey) { + synchronized (cache) { + var entry = cache.get(cacheKey); + return entry == null || entry.isExpired(ttl) ? null : entry.getFullResult(); + } + } + + /** + * Get an in-progress entry if exists. + */ + public InProgressEntry getInProgress(String cacheKey) { + return inProgress.get(cacheKey); + } + + /** + * Wait for collection to complete (up to maxWaitMs) and return the result. + */ + public FcliToolResult waitForCompletion(String cacheKey, long maxWaitMs) { + var entry = inProgress.get(cacheKey); + if (entry == null) { + return getCached(cacheKey); + } + + long start = System.currentTimeMillis(); + while (!entry.isCompleted() && System.currentTimeMillis() - start < maxWaitMs) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + if (entry.isCompleted()) { + inProgress.remove(cacheKey); + return getCached(cacheKey); + } + + return null; // Still in progress + } + + /** + * Cancel a background collection. + */ + public boolean cancel(String cacheKey) { + var entry = inProgress.get(cacheKey); + if (entry != null) { + entry.cancel(); + inProgress.remove(cacheKey); + log.debug("Cancelled collection: cacheKey={}", cacheKey); + return true; + } + return false; + } + + /** + * Clear a specific cache entry. + */ + public boolean clear(String cacheKey) { + boolean removed = false; + synchronized (cache) { + removed = cache.remove(cacheKey) != null; + } + var inProg = inProgress.remove(cacheKey); + if (inProg != null) { + inProg.cancel(); + removed = true; + } + return removed; + } + + /** + * Clear all cache entries. + */ + public void clearAll() { + synchronized (cache) { + cache.clear(); + } + inProgress.values().forEach(InProgressEntry::cancel); + inProgress.clear(); + log.debug("Cleared all cache entries"); + } + + /** + * Get cache statistics. + */ + public CacheStats getStats() { + int cached; + synchronized (cache) { + cached = cache.size(); + } + return new CacheStats(cached, inProgress.size()); + } + + /** + * Shutdown the cache and background executor. + */ + public void shutdown() { + backgroundExecutor.shutdown(); + try { + backgroundExecutor.awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + backgroundExecutor.shutdownNow(); + log.info("FcliRecordsCache shutdown complete"); + } + + /** + * In-progress tracking entry giving access to partial records list. + */ + @Data + public static final class InProgressEntry { + private final String cacheKey; + private final String command; + private final long created = System.currentTimeMillis(); + private final CopyOnWriteArrayList records = new CopyOnWriteArrayList<>(); + private volatile CompletableFuture future; + private volatile boolean completed = false; + private volatile int exitCode = 0; + private volatile String stderr = ""; + + public InProgressEntry(String cacheKey, String command) { + this.cacheKey = cacheKey; + this.command = command; + } + + public boolean isExpired(long ttl) { + return System.currentTimeMillis() > created + ttl; + } + + public void setFuture(CompletableFuture f) { + this.future = f; + } + + public void cancel() { + if (future != null) { + future.cancel(true); + } + } + + public int getLoadedCount() { + return records.size(); + } + + public List getRecordsSnapshot() { + return List.copyOf(records); + } + } + + @Data + @RequiredArgsConstructor + private static final class CacheEntry { + private final FcliToolResult fullResult; + private final long created = System.currentTimeMillis(); + + public boolean isExpired(long ttl) { + return System.currentTimeMillis() > created + ttl; + } + } + + @Data + @RequiredArgsConstructor + public static final class CacheStats { + private final int cachedEntries; + private final int inProgressEntries; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java new file mode 100644 index 0000000000..fb84e71a19 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java @@ -0,0 +1,112 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util._common.helper; + +import java.util.ArrayList; +import java.util.Map; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; +import com.fortify.cli.common.util.OutputHelper.Result; + +/** + * Helper methods for running fcli commands, collecting either records or stdout. + * This class is shared between MCP server and RPC server implementations. + * + * @author Ruud Senden + */ +public class FcliRunnerHelper { + + /** + * Execute a command and collect stdout output. + */ + public static Result collectStdout(String fullCmd) { + return collectStdout(fullCmd, null); + } + + /** + * Execute a command and collect stdout output with default options. + */ + public static Result collectStdout(String fullCmd, Map defaultOptions) { + var builder = FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}); + + if (defaultOptions != null) { + builder.defaultOptionsIfNotPresent(defaultOptions); + } + + return builder.build().create().execute(); + } + + /** + * Execute a command and collect structured records. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer) { + return collectRecords(fullCmd, recordConsumer, null); + } + + /** + * Execute a command and collect structured records with default options. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer, Map defaultOptions) { + var builder = FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(recordConsumer) + .onFail(r -> {}); + + if (defaultOptions != null) { + builder.defaultOptionsIfNotPresent(defaultOptions); + } + + return builder.build().create().execute(); + } + + /** + * Execute a command and return a FcliToolResult with all collected records. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd) { + return collectRecordsAsResult(fullCmd, null); + } + + /** + * Execute a command and return a FcliToolResult with all collected records and default options. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd, Map defaultOptions) { + var records = new ArrayList(); + var result = collectRecords(fullCmd, records::add, defaultOptions); + return FcliToolResult.fromRecords(result, records); + } + + /** + * Execute a command and return a FcliToolResult with stdout. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd) { + return collectStdoutAsResult(fullCmd, null); + } + + /** + * Execute a command and return a FcliToolResult with stdout and default options. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd, Map defaultOptions) { + var result = collectStdout(fullCmd, defaultOptions); + return FcliToolResult.fromPlainText(result); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java new file mode 100644 index 0000000000..2113ef8a6e --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java @@ -0,0 +1,241 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util._common.helper; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliExceptionHelper; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.OutputHelper.Result; + +import lombok.Builder; +import lombok.Data; + +/** + * Unified result class for fcli command execution. Supports multiple output formats: + * plain text (stdout), structured records, paginated records, and errors. + * Null fields are excluded from JSON serialization. + * + * This class is shared between MCP server and RPC server implementations. + * + * @author Ruud Senden + */ +@Data @Builder +@Reflectable +@JsonInclude(Include.NON_NULL) +public class FcliToolResult { + private static final Logger LOG = LoggerFactory.getLogger(FcliToolResult.class); + + // Common fields for all result types + private final Integer exitCode; + private final String stderr; + + // Error fields (populated when exitCode != 0) + private final String error; + private final String errorStackTrace; + private final String errorGuidance; + + // Plain text output + private final String stdout; + + // Structured records output + private final List records; + + // Pagination metadata (for paged results) + private final PageInfo pagination; + + // Factory methods + + /** + * Create result from fcli execution with plain text stdout. + */ + public static FcliToolResult fromPlainText(Result result) { + return builder() + .exitCode(result.getExitCode()) + .stderr(result.getErr()) + .stdout(result.getOut()) + .build(); + } + + /** + * Create result from fcli execution with structured records. + */ + public static FcliToolResult fromRecords(Result result, List records) { + return builder() + .exitCode(result.getExitCode()) + .stderr(result.getErr()) + .records(records) + .build(); + } + + /** + * Create complete paged result once all records have been collected. + */ + public static FcliToolResult fromCompletedPagedResult(FcliToolResult plainResult, int offset, int limit) { + var allRecords = plainResult.getRecords(); + var pageInfo = PageInfo.complete(allRecords.size(), offset, limit); + var endIndexExclusive = Math.min(offset+limit, allRecords.size()); + List pageRecords = offset>=endIndexExclusive ? List.of() : allRecords.subList(offset, endIndexExclusive); + return builder() + .exitCode(plainResult.getExitCode()) + .stderr(plainResult.getStderr()) + .error(plainResult.getError()) + .errorStackTrace(plainResult.getErrorStackTrace()) + .errorGuidance(plainResult.getErrorGuidance()) + .records(pageRecords) + .pagination(pageInfo) + .build(); + } + + /** + * Create partial paged result while background collection is still running. + */ + public static FcliToolResult fromPartialPagedResult(List loadedRecords, int offset, int limit, boolean complete, String cacheKey) { + if ( complete ) { + return fromCompletedPagedResult( + builder().exitCode(0).stderr("").records(loadedRecords).build(), + offset, limit); + } + var endIndexExclusive = Math.min(offset+limit, loadedRecords.size()); + List pageRecords = offset>=endIndexExclusive ? List.of() : loadedRecords.subList(offset, endIndexExclusive); + var hasMore = loadedRecords.size() > offset+limit; + var pageInfo = PageInfo.partial(offset, limit, hasMore).toBuilder().cacheKey(cacheKey).build(); + return builder() + .exitCode(0) + .stderr("") + .records(pageRecords) + .pagination(pageInfo) + .build(); + } + + /** + * Create error result from exit code and stderr message. + */ + public static FcliToolResult fromError(int exitCode, String stderr) { + return builder() + .exitCode(exitCode) + .stderr(stderr != null ? stderr : "Unknown error") + .records(List.of()) + .build(); + } + + /** + * Create error result from exception with structured error information. + */ + public static FcliToolResult fromError(Exception e) { + return builder() + .exitCode(1) + .stderr(getErrorMessage(e)) + .error(getErrorMessage(e)) + .errorStackTrace(formatException(e)) + .errorGuidance(getErrorGuidance()) + .records(List.of()) + .build(); + } + + /** + * Create error result with simple message. + */ + public static FcliToolResult fromError(String message) { + return fromError(1, message); + } + + // Conversion to JSON + + public final String asJsonString() { + return JsonHelper.getObjectMapper().valueToTree(this).toPrettyString(); + } + + public final JsonNode asJsonNode() { + return JsonHelper.getObjectMapper().valueToTree(this); + } + + // Pagination metadata inner class + + @Data @Builder(toBuilder = true) + @Reflectable + public static final class PageInfo { + private final Integer totalRecords; + private final Integer totalPages; + private final int currentOffset; + private final int currentLimit; + private final Integer nextPageOffset; + private final Integer lastPageOffset; + private final boolean hasMore; + private final boolean complete; + private final String cacheKey; // For RPC: reference to cached result + private final String jobToken; // For MCP: reference to job tracking + private final String guidance; + + public static PageInfo complete(int totalRecords, int offset, int limit) { + var totalPages = (int)Math.ceil((double)totalRecords / (double)limit); + var lastPageOffset = (totalPages - 1) * limit; + var nextPageOffset = offset+limit; + var hasMore = totalRecords>nextPageOffset; + return PageInfo.builder() + .currentLimit(limit) + .currentOffset(offset) + .lastPageOffset(lastPageOffset) + .nextPageOffset(hasMore ? nextPageOffset : null) + .hasMore(hasMore) + .totalRecords(totalRecords) + .totalPages(totalPages) + .complete(true) + .guidance("All records loaded; totals available.") + .build(); + } + + public static PageInfo partial(int offset, int limit, boolean hasMore) { + return PageInfo.builder() + .currentLimit(limit) + .currentOffset(offset) + .nextPageOffset(hasMore ? offset+limit : null) + .hasMore(hasMore) + .complete(false) + .guidance("Partial page; totals unavailable. Use cacheKey/jobToken to wait for completion.") + .build(); + } + + @JsonIgnore + public boolean isComplete() { + return complete; + } + } + + // Exception formatting helpers + + private static String formatException(Exception e) { + return FcliExceptionHelper.formatException(e); + } + + private static String getErrorMessage(Exception e) { + return FcliExceptionHelper.getErrorMessage(e); + } + + private static String getErrorGuidance() { + return """ + The fcli command failed with an exception. You may use the error message and stack trace to: + 1. Diagnose the root cause and suggest corrective actions to resolve the issue + 2. Provide the error details to the user if manual troubleshooting is required + 3. Adjust command parameters or suggest alternative approaches to accomplish the task + """; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java index dae59fa29a..70af487563 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java @@ -13,16 +13,22 @@ package com.fortify.cli.util._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.util.all_commands.cli.cmd.AllCommandsCommands; import com.fortify.cli.util.autocomplete.cli.cmd.AutoCompleteCommands; import com.fortify.cli.util.crypto.cli.cmd.CryptoCommands; import com.fortify.cli.util.mcp_server.cli.cmd.MCPServerCommands; +import com.fortify.cli.util.rpc_server.cli.cmd.RPCServerCommands; import com.fortify.cli.util.sample_data.cli.cmd.SampleDataCommands; import com.fortify.cli.util.state.cli.cmd.StateCommands; import com.fortify.cli.util.variable.cli.cmd.VariableCommands; import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) +@RelatedModules({"ssc","fod"}) @Command( name = "util", resourceBundle = "com.fortify.cli.util.i18n.UtilMessages", @@ -31,6 +37,7 @@ AutoCompleteCommands.class, CryptoCommands.class, MCPServerCommands.class, + RPCServerCommands.class, SampleDataCommands.class, StateCommands.class, VariableCommands.class diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java index 600ac64b67..7570eb0aac 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java @@ -13,12 +13,18 @@ package com.fortify.cli.util.all_commands.cli.mixin; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; @@ -30,7 +36,10 @@ import lombok.Data; import lombok.Getter; +import picocli.CommandLine.Model.ArgGroupSpec; import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Model.PositionalParamSpec; import picocli.CommandLine.Option; /** @@ -41,6 +50,10 @@ public class AllCommandsCommandSelectorMixin { @Option(names = {"-q", "--query"}, order=1, converter = QueryExpressionTypeConverter.class, paramLabel = "") @Getter private QueryExpression queryExpression; + private static final String HEADING_COMMAND_OPTIONS = "Command Options"; + private static final String HEADING_OUTPUT_OPTIONS = "Output options"; + private static final String HEADING_GENERIC_OPTIONS = "Generic fcli options"; + public final IObjectNodeProducer getObjectNodeProducer() { return StreamingObjectNodeProducer.builder() .streamSupplier(this::createObjectNodeStream) @@ -48,25 +61,25 @@ public final IObjectNodeProducer getObjectNodeProducer() { } public final Stream createObjectNodeStream() { - return createStream().map(n->n.getNode()); + return createStream().map(CommandSpecAndNode::getNode); } - + public final Stream createCommandSpecStream() { - return createStream().map(n->n.getSpec()); + return createStream().map(CommandSpecAndNode::getSpec); } - + private final Stream createStream() { return FcliCommandSpecHelper.rootCommandTreeStream() - .map(CommandSpecAndNode::new) - .filter(n->n.matches(queryExpression)) - .distinct(); + .map(CommandSpecAndNode::new) + .filter(n -> n.matches(queryExpression)) + .distinct(); } - + @Data - private static final class CommandSpecAndNode { + private static final class CommandSpecAndNode { private final CommandSpec spec; private final ObjectNode node; - + private CommandSpecAndNode(CommandSpec spec) { this.spec = spec; this.node = createNode(spec); @@ -82,10 +95,22 @@ private static final ObjectNode createNode(CommandSpec spec) { var hiddenSelf = FcliCommandSpecHelper.isHiddenSelf(spec); var hidden = FcliCommandSpecHelper.isHiddenSelfOrParent(spec); var mcpIgnored = FcliCommandSpecHelper.isMcpIgnored(spec); - var nameComponents = spec.qualifiedName(" ").split(" "); - var module = nameComponents.length>1 ? nameComponents[1] : ""; - var entity = nameComponents.length>2 ? nameComponents[2] : ""; - var action = nameComponents.length>3 ? nameComponents[3] : ""; + + String qualifiedName = spec.qualifiedName(" "); + String[] nameComponents = qualifiedName.split(" "); + String module = nameComponents.length > 1 ? nameComponents[1] : ""; + String entity = nameComponents.length > 2 ? nameComponents[2] : ""; + String action = nameComponents.length > 3 ? nameComponents[3] : ""; + + Map requiredByOption = new HashMap<>(); + for (OptionSpec option : spec.options()) { + boolean required = isEffectivelyRequired(option, spec.argGroups()); + requiredByOption.put(option, required); + } + + List exclusiveGroups = new ArrayList<>(); + collectExclusiveGroups(spec.argGroups(), exclusiveGroups); + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("command", spec.qualifiedName(" ")); result.put("module", module); @@ -96,59 +121,470 @@ private static final ObjectNode createNode(CommandSpec spec) { result.put("hiddenSelf", hiddenSelf); result.put("mcpIgnored", mcpIgnored); result.put("runnable", FcliCommandSpecHelper.isRunnable(spec)); - result.put("usageHeader", String.join("\n", spec.usageMessage().header())); + result.put("usageHeader", normalizeNewlines(String.join("\n", spec.usageMessage().header()))); + result.put("usageDescription", normalizeNewlines(String.join("\n", spec.usageMessage().description()))); result.set("aliases", Stream.of(spec.aliases()).map(TextNode::new).collect(JsonHelper.arrayNodeCollector())); result.put("aliasesString", Stream.of(spec.aliases()).collect(Collectors.joining(", "))); var fullAliases = computeFullAliases(spec); result.set("fullAliases", fullAliases.stream().map(TextNode::new).collect(JsonHelper.arrayNodeCollector())); result.put("fullAliasesString", String.join(", ", fullAliases)); + result.set("options", spec.optionsMap().keySet().stream().map(TextNode::new).collect(JsonHelper.arrayNodeCollector())); result.put("optionsString", spec.optionsMap().keySet().stream().collect(Collectors.joining(", "))); + result.set("commandArgs", createCommandArgsNode(spec, requiredByOption, exclusiveGroups)); + + return result; + } + + private final static ObjectNode createCommandArgsNode(CommandSpec spec, Map requiredByOption, + List exclusiveGroups) { + var mapper = JsonHelper.getObjectMapper(); + ObjectNode commandArgs = mapper.createObjectNode(); + + ArrayNode parameters = mapper.createArrayNode(); + for (PositionalParamSpec param : spec.positionalParameters()) { + parameters.add(createParameterNode(param)); + } + commandArgs.set("parameters", parameters); + + Map> optionsByHeading = new LinkedHashMap<>(); + Map optionToGroup = buildOptionToGroupMap(spec); + + for (OptionSpec option : spec.options()) { + if (option.hidden()) { + continue; + } + String heading = getOptionGroupHeading(option, optionToGroup); + optionsByHeading.computeIfAbsent(heading, h -> new ArrayList<>()).add(option); + } + + // Build exclusive group metadata: map each child subgroup to its sibling IDs + Map> exclusiveWithById = buildExclusiveWithMap(exclusiveGroups); + + // Track options that are part of exclusive sub-groups (to avoid duplicates) + Set optionsInSubGroups = new LinkedHashSet<>(); + + // Build per-heading exclusive subGroups + Map> exclusiveSubGroupsByGroupId = buildExclusiveSubGroups(exclusiveGroups, optionToGroup, exclusiveWithById, requiredByOption, optionsInSubGroups); + + ArrayNode optionGroups = mapper.createArrayNode(); + + java.util.function.Consumer addGroupByHeading = heading -> { + List opts = optionsByHeading.get(heading); + if (opts == null || opts.isEmpty()) { + return; + } + ObjectNode groupNode = mapper.createObjectNode(); + String groupId = toGroupId(heading); + groupNode.put("title", heading); + groupNode.put("id", groupId); + + // Top-level options: only those NOT in any exclusive subgroup + ArrayNode optionsArray = mapper.createArrayNode(); + for (OptionSpec opt : opts) { + if (!optionsInSubGroups.contains(opt)) { + optionsArray.add(createOptionNode(opt, requiredByOption.getOrDefault(opt, false))); + } + } + groupNode.set("options", optionsArray); + + ArrayNode subGroupsArray = mapper.createArrayNode(); + List subGroups = exclusiveSubGroupsByGroupId.get(groupId); + if (subGroups != null) { + subGroups.forEach(subGroupsArray::add); + } + groupNode.set("subGroups", subGroupsArray); + + optionGroups.add(groupNode); + }; + + addGroupByHeading.accept(HEADING_COMMAND_OPTIONS); + + List allHeadings = new ArrayList<>(optionsByHeading.keySet()); + for (String heading : allHeadings) { + if (HEADING_COMMAND_OPTIONS.equals(heading) + || HEADING_OUTPUT_OPTIONS.equals(heading) + || HEADING_GENERIC_OPTIONS.equals(heading)) { + continue; + } + addGroupByHeading.accept(heading); + } + + addGroupByHeading.accept(HEADING_OUTPUT_OPTIONS); + addGroupByHeading.accept(HEADING_GENERIC_OPTIONS); + + commandArgs.set("optionGroups", optionGroups); + return commandArgs; + } + + private final static Map> buildExclusiveWithMap(List exclusiveGroups) { + Map> result = new LinkedHashMap<>(); + for (ArgGroupSpec exclusiveGroup : exclusiveGroups) { + List children = exclusiveGroup.subgroups(); + if (children.size() < 2) { + continue; + } + List childIds = children.stream().map(child -> toGroupId(computeGroupTitle(child))).collect(Collectors.toList()); + for (int i = 0; i < children.size(); i++) { + String thisId = childIds.get(i); + List siblings = new ArrayList<>(); + for (int j = 0; j < children.size(); j++) { + if (j != i) { + siblings.add(childIds.get(j)); + } + } + result.put(thisId, siblings); + } + } + return result; + } + + private final static Map> buildExclusiveSubGroups( + List exclusiveGroups, + Map optionToGroup, + Map> exclusiveWithById, + Map requiredByOption, + Set optionsInSubGroups) { + var mapper = JsonHelper.getObjectMapper(); + Map> result = new LinkedHashMap<>(); + + for (ArgGroupSpec exclusiveGroup : exclusiveGroups) { + for (ArgGroupSpec child : exclusiveGroup.subgroups()) { + // Collect all (non-hidden) options in this child subgroup + List childOptions = collectAllOptions(child).stream().filter(o -> !o.hidden()).distinct().collect(Collectors.toList()); + if (childOptions.isEmpty()) { + continue; + } + + // Determine which top-level heading group this subgroup belongs to + OptionSpec firstOpt = childOptions.get(0); + String parentHeading = getOptionGroupHeading(firstOpt, optionToGroup); + String parentGroupId = toGroupId(parentHeading); + + String title = computeGroupTitle(child); + String groupId = toGroupId(title); + + ObjectNode groupNode = mapper.createObjectNode(); + groupNode.put("id", groupId); + groupNode.put("title", title); + + // Full option metadata for this subgroup + ArrayNode optionsArray = mapper.createArrayNode(); + for (OptionSpec opt : childOptions) { + optionsArray.add(createOptionNode(opt, requiredByOption.getOrDefault(opt, false))); + optionsInSubGroups.add(opt); + } + groupNode.set("options", optionsArray); + + List siblings = exclusiveWithById.get(groupId); + if (siblings != null && !siblings.isEmpty()) { + ArrayNode exclusiveWithArray = mapper.createArrayNode(); + siblings.forEach(exclusiveWithArray::add); + groupNode.set("exclusiveWith", exclusiveWithArray); + } + + result.computeIfAbsent(parentGroupId, k -> new ArrayList<>()).add(groupNode); + } + } + return result; + } + + private final static List collectAllOptions(ArgGroupSpec group) { + List result = new ArrayList<>(group.options()); + for (ArgGroupSpec sub : group.subgroups()) { + result.addAll(collectAllOptions(sub)); + } + return result; + } + + private final static Map buildOptionToGroupMap(CommandSpec spec) { + Map map = new HashMap<>(); + collectOptionToGroup(spec.argGroups(), map); + return map; + } + + private final static void collectOptionToGroup(Collection groups, Map map) { + for (ArgGroupSpec group : groups) { + for (OptionSpec opt : group.options()) { + map.put(opt, group); + } + collectOptionToGroup(group.subgroups(), map); + } + } + + private final static String getOptionGroupHeading(OptionSpec option, Map optionToGroup) { + ArgGroupSpec group = optionToGroup.get(option); + String heading = null; + if (group != null) { + if (group.heading() != null && !group.heading().isBlank()) { + heading = group.heading().replace("%n", "").trim(); + } else if (group.headingKey() != null && !group.headingKey().isBlank()) { + heading = group.headingKey().trim(); + } + } + if (heading == null) { + heading = HEADING_COMMAND_OPTIONS; + } + int idx = heading.indexOf(" ("); + if (idx > 0) { + heading = heading.substring(0, idx).trim(); + } + return heading; + } + + private final static ObjectNode createOptionNode(OptionSpec option, boolean required) { + ObjectNode node = JsonHelper.getObjectMapper().createObjectNode(); + String title = computeTitleFromOption(option); + node.put("title", title); + node.set("names", JsonHelper.getObjectMapper().createArrayNode() + .addAll(Arrays.stream(option.names()).map(TextNode::new).collect(Collectors.toList()))); + node.put("primaryName", getPrimaryName(option)); + + String valueFormat = option.paramLabel(); + if (valueFormat == null) { + valueFormat = ""; + } + node.put("valueFormat", valueFormat); + + node.put("description", normalizeNewlines( + option.description().length > 0 ? option.description()[0] : "")); + node.put("required", required); + boolean secret = isSecretOption(option); + ArrayNode allowedValues = getAllowedValues(option, option.type(), + option.type() != null && option.type().isEnum()); + String datatype = getDatatype(option.type(), option.arity(), option.splitRegex(), + allowedValues.size() > 0); + node.put("datatype", datatype); + node.put("secret", secret); + node.put("multiselect", isMultiSelect(option.type(), option.arity(), option.splitRegex())); + node.set("allowedValues", allowedValues); + return node; + } + + private final static ObjectNode createParameterNode(PositionalParamSpec param) { + ObjectNode node = JsonHelper.getObjectMapper().createObjectNode(); + String title = computeTitleFromLabel(param.paramLabel()); + node.put("title", title); + node.put("valueFormat", param.paramLabel()); + node.put("description", normalizeNewlines( + param.description().length > 0 ? param.description()[0] : "")); + node.put("required", param.required()); + return node; + } + + private final static void collectExclusiveGroups( + Collection groups, + List out) { + for (ArgGroupSpec g : groups) { + if (g.exclusive()) { + out.add(g); + } + collectExclusiveGroups(g.subgroups(), out); + } + } + + private final static String computeGroupTitle(ArgGroupSpec group) { + String heading = group.heading(); + if (heading != null && !heading.isBlank()) { + return heading.replace("%n", "").trim(); + } + OptionSpec firstOption = !group.options().isEmpty() + ? group.options().get(0) + : group.subgroups().stream() + .flatMap(g -> g.options().stream()) + .findFirst() + .orElse(null); + if (firstOption != null) { + return computeTitleFromOption(firstOption); + } + return "Arguments"; + } + + private final static boolean isEffectivelyRequired(OptionSpec option, Collection rootGroups) { + if (!option.required()) { + return false; + } + return !isInOptionalGroup(option, rootGroups, false); + } + + private final static boolean isInOptionalGroup( + OptionSpec option, + Collection groups, + boolean parentOptional) { + for (ArgGroupSpec g : groups) { + boolean thisOptional = parentOptional; + var multiplicity = g.multiplicity(); + if (multiplicity != null && multiplicity.min() == 0) { + thisOptional = true; + } + if (g.options().contains(option)) { + return thisOptional; + } + if (isInOptionalGroup(option, g.subgroups(), thisOptional)) { + return true; + } + } + return false; + } + + private final static String getPrimaryName(OptionSpec option) { + String[] names = option.names(); + if (names == null || names.length == 0) { + return null; + } + return Arrays.stream(names) + .filter(n -> n.startsWith("--")) + .findFirst() + .orElse(names[0]); + } + + private final static String getDatatype( + Class type, + picocli.CommandLine.Range arity, + String splitRegex, + boolean hasAllowedValues) { + if (arity != null && arity.max() == 0) { + return "boolean"; + } + if (type == null) { + return "string"; + } + // Treat character arrays as a single string value (e.g. tokens) + if (type.isArray()) { + Class componentType = type.getComponentType(); + if (componentType == char.class || componentType == Character.class) { + return "string"; + } + } + boolean isListType = Collection.class.isAssignableFrom(type) + || type.isArray() + || (splitRegex != null && !splitRegex.isBlank()) + || (arity != null && arity.max() > 1); + if (isListType && hasAllowedValues) { + return "array"; + } + return "string"; + } + + private final static boolean isMultiSelect(Class type, picocli.CommandLine.Range arity, String splitRegex) { + if (arity != null && arity.max() == 0) { + return false; + } + if (type != null && type.isArray()) { + Class componentType = type.getComponentType(); + // Character arrays should be treated as single-valued (e.g. tokens) + if (componentType == char.class || componentType == Character.class) { + return false; + } + } + if (type != null && (type.isArray() || Collection.class.isAssignableFrom(type))) { + return true; + } + if (splitRegex != null && !splitRegex.isBlank()) { + return true; + } + if (arity != null && arity.max() > 1) { + return true; + } + return false; + } + + private final static boolean hasCompletionCandidates(OptionSpec option) { + Iterable candidates = option.completionCandidates(); + if (candidates == null) { + return false; + } + for (@SuppressWarnings("unused") + Object ignored : candidates) { + return true; + } + return false; + } + + private final static ArrayNode getAllowedValues(OptionSpec option, Class type, boolean isEnumType) { + ArrayNode result = JsonHelper.getObjectMapper().createObjectNode().arrayNode(); + if (isEnumType && type != null) { + Object[] constants = type.getEnumConstants(); + if (constants != null) { + for (Object constant : constants) { + result.add(constant.toString()); + } + } + } else { + Iterable candidates = option.completionCandidates(); + if (candidates != null) { + for (Object candidate : candidates) { + result.add(String.valueOf(candidate)); + } + } + } return result; } - /** - * Compute all possible full command aliases for the given {@link CommandSpec} by - * generating the cartesian product of primary names + aliases for every command - * in the hierarchy (root to leaf). The canonical command name (concatenation of - * primary names) is INCLUDED as the first element if there is at least one alias - * somewhere in the hierarchy; if there are no aliases anywhere, an empty list is - * returned. - * - * Example: For hierarchy fcli -> ssc -> appversion (alias: av) -> list (alias: ls), - * this method returns (order preserved): ["fcli ssc appversion list", "fcli ssc appversion ls", - * "fcli ssc av list", "fcli ssc av ls"]. - */ - private static final List computeFullAliases(CommandSpec leafSpec) { - // Build ordered list of specs from root to leaf + private final static String computeTitleFromOption(OptionSpec option) { + String primaryName = getPrimaryName(option); + if (primaryName == null) { + return ""; + } + String withoutDashes = primaryName.replaceFirst("^-+", ""); + return computeTitleFromLabel(withoutDashes); + } + + private final static String computeTitleFromLabel(String label) { + if (label == null) { + return ""; + } + String sanitized = label.replace("<", "").replace(">", "").replace(":", " "); + if (sanitized.isBlank()) { + return ""; + } + String[] parts = sanitized.split("[-_\\s]+"); + return Arrays.stream(parts).filter(p -> !p.isBlank()).map(p -> p.substring(0, 1).toUpperCase() + p.substring(1)).collect(Collectors.joining(" ")); + } + + private final static boolean isSecretOption(OptionSpec option) { + return FcliCommandSpecHelper.isSensitive(option); + } + + private static String toGroupId(String title) { + if (title == null || title.isBlank()) { + return "unknown"; + } + return title.toLowerCase().replaceAll("[^a-z0-9]+", ""); + } + + private final static String normalizeNewlines(String text) { + if (text == null) { + return ""; + } + return text.replace("\n%n", "\n\n").replace("%n", "\n"); + } + + private static List computeFullAliases(CommandSpec leafSpec) { List hierarchy = new ArrayList<>(); for (CommandSpec current = leafSpec; current != null; current = current.parent()) { hierarchy.add(0, current); } - // Collect possible names (primary + aliases) for each spec in hierarchy List> hierarchyNames = new ArrayList<>(); boolean hasAnyAlias = false; for (CommandSpec cs : hierarchy) { List names = new ArrayList<>(); names.add(cs.name()); for (String a : cs.aliases()) { - if (!a.equals(cs.name())) { // avoid duplicate of primary name + if (!a.equals(cs.name())) { names.add(a); hasAnyAlias = true; } } hierarchyNames.add(names); } - if (!hasAnyAlias) { // No aliases anywhere => no full alias combinations + if (!hasAnyAlias) { return List.of(); } - // Cartesian product Set combinations = new LinkedHashSet<>(); buildCombinations(hierarchyNames, 0, new ArrayList<>(), combinations); - // Ensure canonical (all primary names) appears first if present String canonical = hierarchy.stream().map(CommandSpec::name).collect(Collectors.joining(" ")); if (combinations.remove(canonical)) { - // Re-insert at beginning by creating new list List ordered = new ArrayList<>(); ordered.add(canonical); ordered.addAll(combinations); @@ -157,7 +593,7 @@ private static final List computeFullAliases(CommandSpec leafSpec) { return new ArrayList<>(combinations); } - private static final void buildCombinations(List> hierarchyNames, int index, List current, Set out) { + private static void buildCombinations(List> hierarchyNames, int index, List current, Set out) { if (index == hierarchyNames.size()) { out.add(String.join(" ", current)); return; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java new file mode 100644 index 0000000000..abea2e15a1 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +/** + * Container command for JSON-RPC server commands. + * + * @author Ruud Senden + */ +@Command( + name = "rpc-server", + subcommands = { + RPCServerStartCommand.class + } +) +public class RPCServerCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java new file mode 100644 index 0000000000..347792dc73 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.cli.cmd; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.util.rpc_server.helper.rpc.JsonRpcServer; + +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; + +/** + * Command to start the fcli JSON-RPC server for IDE plugin integration. + * The server listens on stdin/stdout for JSON-RPC 2.0 requests and processes + * them synchronously. + * + * @author Ruud Senden + */ +@Command(name = OutputHelperMixins.Start.CMD_NAME) +@MCPExclude +@Slf4j +public class RPCServerStartCommand extends AbstractRunnableCommand { + + @Override + public Integer call() throws Exception { + log.info("Starting JSON-RPC server"); + + var objectMapper = new ObjectMapper(); + var server = new JsonRpcServer(objectMapper); + + // Start the server on stdin/stdout + server.start(System.in, System.out); + + return 0; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java new file mode 100644 index 0000000000..8d636686f0 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Interface for JSON-RPC method handlers. Each handler is responsible for + * executing a specific RPC method and returning the result. + * + * @author Ruud Senden + */ +@FunctionalInterface +public interface IRpcMethodHandler { + /** + * Execute the RPC method with the given parameters. + * + * @param params the method parameters (may be null) + * @return the result as a JsonNode, or null if no result + * @throws RpcMethodException if the method execution fails + */ + JsonNode execute(JsonNode params) throws RpcMethodException; +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java new file mode 100644 index 0000000000..30d013c0d6 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 error object. Per specification: + * - code: Integer indicating the error type + * - message: String providing a short description of the error + * - data: Optional value containing additional information about the error + * + * Standard error codes: + * -32700: Parse error + * -32600: Invalid Request + * -32601: Method not found + * -32602: Invalid params + * -32603: Internal error + * -32000 to -32099: Server error (reserved for implementation-defined errors) + * + * @author Ruud Senden + */ +@Reflectable +@JsonInclude(Include.NON_NULL) +public record JsonRpcError( + int code, + String message, + JsonNode data +) { + public static final int PARSE_ERROR = -32700; + public static final int INVALID_REQUEST = -32600; + public static final int METHOD_NOT_FOUND = -32601; + public static final int INVALID_PARAMS = -32602; + public static final int INTERNAL_ERROR = -32603; + public static final int SERVER_ERROR = -32000; + + public static JsonRpcError parseError() { + return new JsonRpcError(PARSE_ERROR, "Parse error", null); + } + + public static JsonRpcError invalidRequest() { + return new JsonRpcError(INVALID_REQUEST, "Invalid Request", null); + } + + public static JsonRpcError methodNotFound(String method) { + return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null); + } + + public static JsonRpcError invalidParams(String details) { + return new JsonRpcError(INVALID_PARAMS, "Invalid params: " + details, null); + } + + public static JsonRpcError internalError(String details) { + return new JsonRpcError(INTERNAL_ERROR, "Internal error: " + details, null); + } + + public static JsonRpcError serverError(int code, String message, JsonNode data) { + if (code > SERVER_ERROR || code < SERVER_ERROR - 99) { + code = SERVER_ERROR; + } + return new JsonRpcError(code, message, data); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java new file mode 100644 index 0000000000..ff806274dd --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 request object. Per specification: + * - jsonrpc: MUST be "2.0" + * - method: String containing the name of the method to be invoked + * - params: Optional structured value holding parameter values + * - id: An identifier established by the client (can be string, number, or null for notifications) + * + * @author Ruud Senden + */ +@Reflectable +@JsonIgnoreProperties(ignoreUnknown = true) +public record JsonRpcRequest( + String jsonrpc, + String method, + JsonNode params, + JsonNode id +) { + public boolean isNotification() { + return id == null || id.isNull(); + } + + public boolean isValid() { + return "2.0".equals(jsonrpc) && method != null && !method.isBlank(); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java new file mode 100644 index 0000000000..3f6b4a7695 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 response object. Per specification: + * - jsonrpc: MUST be "2.0" + * - result: Required on success. Value determined by method invocation. + * - error: Required on error. Error object describing the error. + * - id: MUST be same as request id, or null if id couldn't be determined + * + * @author Ruud Senden + */ +@Reflectable +@JsonInclude(Include.NON_NULL) +public record JsonRpcResponse( + String jsonrpc, + JsonNode result, + JsonRpcError error, + JsonNode id +) { + public static JsonRpcResponse success(JsonNode id, JsonNode result) { + return new JsonRpcResponse("2.0", result, null, id); + } + + public static JsonRpcResponse error(JsonNode id, JsonRpcError error) { + return new JsonRpcResponse("2.0", null, error, id); + } + + public static JsonRpcResponse parseError() { + return error(null, JsonRpcError.parseError()); + } + + public static JsonRpcResponse invalidRequest(JsonNode id) { + return error(id, JsonRpcError.invalidRequest()); + } + + public static JsonRpcResponse methodNotFound(JsonNode id, String method) { + return error(id, JsonRpcError.methodNotFound(method)); + } + + public static JsonRpcResponse invalidParams(JsonNode id, String message) { + return error(id, JsonRpcError.invalidParams(message)); + } + + public static JsonRpcResponse internalError(JsonNode id, String message) { + return error(id, JsonRpcError.internalError(message)); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java new file mode 100644 index 0000000000..1dad92274a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -0,0 +1,239 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.extern.slf4j.Slf4j; + +/** + * A lightweight JSON-RPC 2.0 server that reads requests from an input stream + * and writes responses to an output stream (typically stdin/stdout for IDE integration). + * + * This implementation: + * - Supports JSON-RPC 2.0 specification + * - Handles single requests and batch requests + * - Supports notifications (requests without id) + * - Is compatible with GraalVM native image compilation + * - Processes requests synchronously (appropriate for stdio-based IDE integration) + * - Includes caching for efficient paged access to large result sets + * - Manages product sessions (SSC, FoD) with automatic cleanup on shutdown + * + * @author Ruud Senden + */ +@Slf4j +public final class JsonRpcServer { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + private final AtomicBoolean running = new AtomicBoolean(false); + private final FcliRecordsCache cache; + private final RpcSessionManager sessionManager; + + public JsonRpcServer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.methodHandlers = new LinkedHashMap<>(); + this.cache = new FcliRecordsCache(); + this.sessionManager = new RpcSessionManager(objectMapper); + + // Configure cache to use session manager for resolving session options + this.cache.setOptionResolver(sessionManager::getSessionOptionsForCommand); + + registerDefaultMethods(); + } + + private void registerDefaultMethods() { + // Register built-in fcli methods + registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper, sessionManager)); + registerMethod("fcli.executeAsync", new RpcMethodHandlerFcliExecuteAsync(objectMapper, cache)); + registerMethod("fcli.getPage", new RpcMethodHandlerFcliGetPage(objectMapper, cache)); + registerMethod("fcli.cancelCollection", new RpcMethodHandlerFcliCancelCollection(objectMapper, cache)); + registerMethod("fcli.clearCache", new RpcMethodHandlerFcliClearCache(objectMapper, cache)); + registerMethod("fcli.listCommands", new RpcMethodHandlerFcliListCommands(objectMapper)); + registerMethod("fcli.version", new RpcMethodHandlerFcliVersion(objectMapper)); + registerMethod("rpc.listMethods", new RpcMethodHandlerListMethods(objectMapper, methodHandlers)); + + // Register product-specific session methods + for (var entry : sessionManager.getLoginHandlers().entrySet()) { + registerMethod("fcli." + entry.getKey() + ".login", entry.getValue()); + } + for (var entry : sessionManager.getLogoutHandlers().entrySet()) { + registerMethod("fcli." + entry.getKey() + ".logout", entry.getValue()); + } + } + + /** + * Register a custom method handler. + */ + public void registerMethod(String methodName, IRpcMethodHandler handler) { + methodHandlers.put(methodName, handler); + log.debug("Registered RPC method: {}", methodName); + } + + /** + * Start the server, reading from the given input stream and writing to the output stream. + * This method blocks until the input stream is closed or an error occurs. + * Requests are processed synchronously in the order they are received. + */ + public void start(InputStream input, OutputStream output) { + running.set(true); + log.info("JSON-RPC server starting on stdio"); + System.err.println("Fcli JSON-RPC server running on stdio. Hit Ctrl-C to exit."); + + try (var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + var writer = new PrintWriter(output, true, StandardCharsets.UTF_8)) { + + String line; + while (running.get() && (line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + + log.debug("Received request: {}", line); + + String responseJson = processRequest(line); + if (responseJson != null) { + log.debug("Sending response: {}", responseJson); + writer.println(responseJson); + } + } + } catch (Exception e) { + log.error("Error in JSON-RPC server", e); + } finally { + running.set(false); + // Logout all sessions on shutdown + sessionManager.logoutAll(); + cache.shutdown(); + log.info("JSON-RPC server stopped"); + } + } + + /** + * Stop the server gracefully. + */ + public void stop() { + running.set(false); + } + + /** + * Process a single JSON-RPC request line and return the response JSON. + * Returns null for notifications (requests without id). + */ + public String processRequest(String requestJson) { + try { + JsonNode requestNode = objectMapper.readTree(requestJson); + + // Check for batch request + if (requestNode.isArray()) { + return processBatchRequest((ArrayNode) requestNode); + } + + // Single request + return processSingleRequest(requestNode); + } catch (JsonProcessingException e) { + log.warn("Failed to parse JSON-RPC request: {}", e.getMessage()); + return toJson(JsonRpcResponse.parseError()); + } + } + + private String processBatchRequest(ArrayNode requests) { + if (requests.isEmpty()) { + return toJson(JsonRpcResponse.invalidRequest(null)); + } + + ArrayNode responses = objectMapper.createArrayNode(); + for (JsonNode request : requests) { + String responseJson = processSingleRequest(request); + if (responseJson != null) { + try { + responses.add(objectMapper.readTree(responseJson)); + } catch (JsonProcessingException e) { + log.error("Error processing batch response", e); + } + } + } + + // If all requests were notifications, return nothing + if (responses.isEmpty()) { + return null; + } + + return toJson(responses); + } + + private String processSingleRequest(JsonNode requestNode) { + JsonRpcRequest request; + try { + request = objectMapper.treeToValue(requestNode, JsonRpcRequest.class); + } catch (JsonProcessingException e) { + return toJson(JsonRpcResponse.invalidRequest(null)); + } + + if (request == null || !request.isValid()) { + return toJson(JsonRpcResponse.invalidRequest(request != null ? request.id() : null)); + } + + // Process the method + JsonRpcResponse response = executeMethod(request); + + // Don't return response for notifications + if (request.isNotification()) { + return null; + } + + return toJson(response); + } + + private JsonRpcResponse executeMethod(JsonRpcRequest request) { + var handler = methodHandlers.get(request.method()); + if (handler == null) { + return JsonRpcResponse.methodNotFound(request.id(), request.method()); + } + + try { + JsonNode result = handler.execute(request.params()); + return JsonRpcResponse.success(request.id(), result); + } catch (RpcMethodException e) { + return JsonRpcResponse.error(request.id(), e.toJsonRpcError()); + } catch (Exception e) { + log.error("Unexpected error executing method {}: {}", request.method(), e.getMessage(), e); + return JsonRpcResponse.internalError(request.id(), e.getMessage()); + } + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("Failed to serialize response", e); + // Fallback to a hardcoded error response to avoid infinite recursion + // if serialization itself fails + return String.format( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":%d,\"message\":\"Internal error: serialization failed\"},\"id\":null}", + JsonRpcError.INTERNAL_ERROR); + } + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java new file mode 100644 index 0000000000..765dfbe5e1 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Exception thrown by RPC method handlers to indicate a method execution error. + * This maps to JSON-RPC error responses. + * + * @author Ruud Senden + */ +public class RpcMethodException extends Exception { + private final int code; + private final JsonNode data; + + public RpcMethodException(int code, String message) { + this(code, message, null, null); + } + + public RpcMethodException(int code, String message, JsonNode data) { + this(code, message, data, null); + } + + public RpcMethodException(int code, String message, JsonNode data, Throwable cause) { + super(message, cause); + this.code = code; + this.data = data; + } + + public int getCode() { + return code; + } + + public JsonNode getData() { + return data; + } + + public JsonRpcError toJsonRpcError() { + return new JsonRpcError(code, getMessage(), data); + } + + public static RpcMethodException invalidParams(String message) { + return new RpcMethodException(JsonRpcError.INVALID_PARAMS, message); + } + + public static RpcMethodException internalError(String message) { + return new RpcMethodException(JsonRpcError.INTERNAL_ERROR, message); + } + + public static RpcMethodException internalError(String message, Throwable cause) { + return new RpcMethodException(JsonRpcError.INTERNAL_ERROR, message, null, cause); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java new file mode 100644 index 0000000000..fc1f180f4d --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for cancelling an in-progress collection. + * + * Method: fcli.cancelCollection + * Params: + * - cacheKey (string, required): Cache key from fcli.executeAsync + * + * Returns: + * - success (boolean): Whether cancellation was successful + * - message (string): Human-readable status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliCancelCollection implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("cacheKey")) { + throw RpcMethodException.invalidParams("'cacheKey' parameter is required"); + } + + var cacheKey = params.get("cacheKey").asText(); + if (cacheKey == null || cacheKey.isBlank()) { + throw RpcMethodException.invalidParams("'cacheKey' cannot be empty"); + } + + log.debug("Cancelling collection: cacheKey={}", cacheKey); + + var cancelled = cache.cancel(cacheKey); + + ObjectNode result = objectMapper.createObjectNode(); + result.put("success", cancelled); + result.put("cacheKey", cacheKey); + result.put("message", cancelled + ? "Collection cancelled successfully" + : "No in-progress collection found for this cacheKey"); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java new file mode 100644 index 0000000000..d70312633d --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for clearing cache entries. + * + * Method: fcli.clearCache + * Params: + * - cacheKey (string, optional): Specific cache key to clear. If not provided, clears all. + * + * Returns: + * - success (boolean): Whether operation was successful + * - message (string): Human-readable status message + * - stats (object, optional): Cache statistics after clearing + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliClearCache implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var cacheKey = params != null && params.has("cacheKey") + ? params.get("cacheKey").asText() + : null; + + ObjectNode result = objectMapper.createObjectNode(); + + if (cacheKey != null && !cacheKey.isBlank()) { + log.debug("Clearing cache entry: cacheKey={}", cacheKey); + var cleared = cache.clear(cacheKey); + result.put("success", cleared); + result.put("cacheKey", cacheKey); + result.put("message", cleared + ? "Cache entry cleared successfully" + : "No cache entry found for this cacheKey"); + } else { + log.debug("Clearing all cache entries"); + cache.clearAll(); + result.put("success", true); + result.put("message", "All cache entries cleared"); + } + + // Add current stats + var stats = cache.getStats(); + ObjectNode statsNode = result.putObject("stats"); + statsNode.put("cachedEntries", stats.getCachedEntries()); + statsNode.put("inProgressEntries", stats.getInProgressEntries()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java new file mode 100644 index 0000000000..d986618882 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java @@ -0,0 +1,125 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.ArrayList; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; +import com.fortify.cli.common.util.OutputHelper.Result; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for executing fcli commands synchronously. + * + * Method: fcli.execute + * Params: + * - command (string, required): The fcli command to execute (e.g., "ssc appversion list") + * - collectRecords (boolean, optional): If true, collect structured records instead of stdout + * + * Returns: + * - exitCode (integer): The command exit code + * - records (array, optional): Array of ALL record objects if collectRecords=true + * - stdout (string, optional): Standard output if collectRecords=false + * - stderr (string): Standard error output + * + * Note: This method returns ALL records without paging. For commands that may return + * large datasets (e.g., issue list), use fcli.executeAsync + fcli.getPage instead. + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecute implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("command")) { + throw RpcMethodException.invalidParams("'command' parameter is required"); + } + + var command = params.get("command").asText(); + var collectRecords = params.has("collectRecords") && params.get("collectRecords").asBoolean(false); + + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Executing fcli command: {} (collectRecords={})", command, collectRecords); + + try { + if (collectRecords) { + return executeWithRecords(command); + } else { + return executeWithStdout(command); + } + } catch (Exception e) { + log.error("Error executing fcli command: {}", command, e); + throw RpcMethodException.internalError("Command execution failed: " + e.getMessage(), e); + } + } + + private JsonNode executeWithStdout(String command) { + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) + .onFail(r -> {}) + .build().create().execute(); + + return buildResponse(result, null); + } + + private JsonNode executeWithRecords(String command) { + var allRecords = new ArrayList(); + + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(allRecords::add) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) + .onFail(r -> {}) + .build().create().execute(); + + return buildResponse(result, allRecords); + } + + private ObjectNode buildResponse(Result result, java.util.List records) { + var response = objectMapper.createObjectNode(); + response.put("exitCode", result.getExitCode()); + + if (records != null) { + ArrayNode recordsArray = response.putArray("records"); + records.forEach(recordsArray::add); + response.put("totalRecords", records.size()); + } else { + response.put("stdout", result.getOut()); + } + + if (result.getErr() != null && !result.getErr().isBlank()) { + response.put("stderr", result.getErr()); + } + + return response; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java new file mode 100644 index 0000000000..3df184848a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for starting async fcli command execution with caching. + * + * Method: fcli.executeAsync + * Params: + * - command (string, required): The fcli command to execute (e.g., "ssc issue list") + * + * Returns: + * - cacheKey (string): Key to retrieve results via fcli.getPage + * - status (string): "started" or "cached" + * - message (string): Human-readable status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecuteAsync implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("command")) { + throw RpcMethodException.invalidParams("'command' parameter is required"); + } + + var command = params.get("command").asText(); + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Starting async execution: command={}", command); + + try { + var cacheKey = cache.startBackgroundCollection(command); + + ObjectNode result = objectMapper.createObjectNode(); + result.put("cacheKey", cacheKey); + result.put("status", "started"); + result.put("message", "Background collection started. Use fcli.getPage with this cacheKey to retrieve results."); + + return result; + } catch (Exception e) { + log.error("Error starting async execution: {}", command, e); + throw RpcMethodException.internalError("Failed to start async execution: " + e.getMessage(), e); + } + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java new file mode 100644 index 0000000000..01d2493680 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java @@ -0,0 +1,196 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; +import com.fortify.cli.util._common.helper.FcliToolResult; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for retrieving a page of results from cache. + * + * Method: fcli.getPage + * Params: + * - cacheKey (string, required): Cache key from fcli.executeAsync + * - offset (integer, optional): Start offset (default: 0) + * - limit (integer, optional): Maximum records to return (default: 100) + * - wait (boolean, optional): If true, wait for completion if still loading (default: false) + * - waitTimeoutMs (integer, optional): Max time to wait in ms (default: 30000) + * + * Returns: + * - status (string): "complete", "partial", "loading", "not_found", or "error" + * - records (array): Array of record objects for this page + * - pagination (object): Pagination metadata + * - loadedCount (integer): Number of records loaded so far + * - exitCode (integer, optional): Command exit code if complete + * - stderr (string, optional): Error output if any + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliGetPage implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("cacheKey")) { + throw RpcMethodException.invalidParams("'cacheKey' parameter is required"); + } + + var cacheKey = params.get("cacheKey").asText(); + var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; + var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; + var wait = params.has("wait") && params.get("wait").asBoolean(false); + var waitTimeoutMs = params.has("waitTimeoutMs") ? params.get("waitTimeoutMs").asInt(30000) : 30000; + + if (cacheKey == null || cacheKey.isBlank()) { + throw RpcMethodException.invalidParams("'cacheKey' cannot be empty"); + } + + if (offset < 0) { + throw RpcMethodException.invalidParams("'offset' must be non-negative"); + } + + if (limit <= 0) { + throw RpcMethodException.invalidParams("'limit' must be greater than 0"); + } + + log.debug("Getting page: cacheKey={} offset={} limit={} wait={}", cacheKey, offset, limit, wait); + + try { + // If wait requested, wait for completion first + if (wait) { + var waitResult = cache.waitForCompletion(cacheKey, waitTimeoutMs); + if (waitResult != null) { + return buildCompletedResponse(waitResult, offset, limit, cacheKey); + } + } + + // Check if we have a cached complete result + var cached = cache.getCached(cacheKey); + if (cached != null) { + return buildCompletedResponse(cached, offset, limit, cacheKey); + } + + // Check if loading is in progress + var inProgress = cache.getInProgress(cacheKey); + if (inProgress != null) { + return buildInProgressResponse(inProgress, offset, limit); + } + + // Not found + return buildNotFoundResponse(cacheKey); + + } catch (Exception e) { + log.error("Error getting page: cacheKey={}", cacheKey, e); + throw RpcMethodException.internalError("Failed to get page: " + e.getMessage(), e); + } + } + + private ObjectNode buildCompletedResponse(FcliToolResult result, int offset, int limit, String cacheKey) { + var allRecords = result.getRecords(); + var totalRecords = allRecords != null ? allRecords.size() : 0; + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", result.getExitCode() == 0 ? "complete" : "error"); + response.put("cacheKey", cacheKey); + response.put("exitCode", result.getExitCode()); + + if (result.getStderr() != null && !result.getStderr().isBlank()) { + response.put("stderr", result.getStderr()); + } + + // Get the requested page + var endIndex = Math.min(offset + limit, totalRecords); + List pageRecords = offset >= totalRecords + ? List.of() + : allRecords.subList(offset, endIndex); + + ArrayNode recordsArray = response.putArray("records"); + pageRecords.forEach(recordsArray::add); + + // Pagination metadata + ObjectNode pagination = response.putObject("pagination"); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("totalRecords", totalRecords); + pagination.put("totalPages", (int) Math.ceil((double) totalRecords / limit)); + pagination.put("hasMore", offset + limit < totalRecords); + pagination.put("complete", true); + if (offset + limit < totalRecords) { + pagination.put("nextOffset", offset + limit); + } + + response.put("loadedCount", totalRecords); + + return response; + } + + private ObjectNode buildInProgressResponse(FcliRecordsCache.InProgressEntry inProgress, int offset, int limit) { + var loadedRecords = inProgress.getRecordsSnapshot(); + var loadedCount = loadedRecords.size(); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", inProgress.isCompleted() ? "complete" : "loading"); + response.put("cacheKey", inProgress.getCacheKey()); + response.put("loadedCount", loadedCount); + + if (inProgress.isCompleted()) { + response.put("exitCode", inProgress.getExitCode()); + if (inProgress.getStderr() != null && !inProgress.getStderr().isBlank()) { + response.put("stderr", inProgress.getStderr()); + } + } + + // Return available records within requested range + var endIndex = Math.min(offset + limit, loadedCount); + List pageRecords = offset >= loadedCount + ? List.of() + : loadedRecords.subList(offset, endIndex); + + ArrayNode recordsArray = response.putArray("records"); + pageRecords.forEach(recordsArray::add); + + // Pagination metadata (partial) + ObjectNode pagination = response.putObject("pagination"); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("hasMore", loadedCount > offset + limit || !inProgress.isCompleted()); + pagination.put("complete", inProgress.isCompleted()); + if (loadedCount > offset + limit) { + pagination.put("nextOffset", offset + limit); + } + pagination.put("guidance", "Collection in progress. Call again with wait=true to wait for completion, or poll periodically."); + + return response; + } + + private ObjectNode buildNotFoundResponse(String cacheKey) { + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", "not_found"); + response.put("cacheKey", cacheKey); + response.put("message", "No cached result or in-progress collection found for this cacheKey. Use fcli.executeAsync to start a new collection."); + response.putArray("records"); + return response; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java new file mode 100644 index 0000000000..9036748344 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java @@ -0,0 +1,267 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Model.CommandSpec; + +/** + * RPC method handler for listing available fcli commands. + * + * Method: fcli.listCommands + * Params: + * - module (string, optional): Filter by module (e.g., "ssc", "fod") + * - runnableOnly (boolean, optional): If true, only return runnable (leaf) + * commands + * - includeHidden (boolean, optional): If true, include hidden commands + * + * Returns: + * - commands (array): Array of command descriptors with: + * - name (string): Qualified command name + * - module (string): The module this command belongs to + * - usageHeader (string): Short description + * - runnable (boolean): Whether the command is executable + * - hidden (boolean): Whether the command is hidden + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliListCommands implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var moduleParam = params != null && params.has("module") + ? params.get("module").asText(null) + : null; + var modulesOnly = params != null && params.has("modulesOnly") + && params.get("modulesOnly").asBoolean(false); + var runnableOnly = params != null && params.has("runnableOnly") + && params.get("runnableOnly").asBoolean(false); + var includeHidden = params != null && params.has("includeHidden") + && params.get("includeHidden").asBoolean(false); + var moduleTypeParam = params != null && params.has("moduleType") + ? params.get("moduleType").asText(null) + : null; + + var requestedModules = parseRequestedModules(moduleParam); + var requestedModuleType = parseRequestedModuleType(moduleTypeParam); + + log.debug("Listing fcli commands (module={}, moduleType={}, runnableOnly={}, includeHidden={}, modulesOnly={})", + moduleParam, moduleTypeParam, runnableOnly, includeHidden, modulesOnly); + + try { + var rootSpec = FcliCommandSpecHelper.getRootCommandLine().getCommandSpec(); + + if (modulesOnly) { + // Special path: return modules (with related + type filter) instead of commands + return listModulesWithRelations(rootSpec, requestedModules, requestedModuleType, runnableOnly, + includeHidden); + } + + // Normal commands listing path + Stream commandStream = FcliCommandSpecHelper.commandTreeStream(rootSpec); + + // Apply module filter (single or multiple) + if (requestedModules != null && !requestedModules.isEmpty()) { + commandStream = commandStream.filter(spec -> { + String qualifiedName = spec.qualifiedName(" "); + String[] parts = qualifiedName.split(" "); + String moduleName = parts.length > 1 ? parts[1] : ""; + return requestedModules.contains(moduleName); + }); + } + + if (runnableOnly) { + commandStream = commandStream.filter(FcliCommandSpecHelper::isRunnable); + } + + if (!includeHidden) { + commandStream = commandStream.filter(spec -> !spec.usageMessage().hidden()); + } + + ArrayNode commands = objectMapper.createArrayNode(); + commandStream + .map(this::specToDescriptor) + .forEach(commands::add); + + ObjectNode result = objectMapper.createObjectNode(); + result.set("commands", commands); + result.put("count", commands.size()); + return result; + } catch (Exception e) { + log.error("Error listing fcli commands", e); + throw RpcMethodException.internalError("Failed to list commands: " + e.getMessage(), e); + } + } + + private ObjectNode specToDescriptor(CommandSpec spec) { + var descriptor = objectMapper.createObjectNode(); + var qualifiedName = spec.qualifiedName(" "); + + descriptor.put("name", qualifiedName); + descriptor.put("module", extractModule(qualifiedName)); + descriptor.put("usageHeader", getUsageHeader(spec)); + descriptor.put("runnable", FcliCommandSpecHelper.isRunnable(spec)); + descriptor.put("hidden", spec.usageMessage().hidden()); + + return descriptor; + } + + private String extractModule(String qualifiedName) { + // Format: "fcli ..." or just "fcli" + var parts = qualifiedName.split(" "); + if (parts.length >= 2) { + return parts[1]; + } + return ""; + } + + private String getUsageHeader(CommandSpec spec) { + var headerLines = spec.usageMessage().header(); + if (headerLines != null && headerLines.length > 0) { + return String.join(" ", headerLines); + } + return ""; + } + + private Set parseRequestedModules(String moduleParam) { + if (moduleParam == null || moduleParam.isBlank()) { + return null; + } + return Stream.of(moduleParam.split("[|,]")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + private JsonNode listModulesWithRelations(CommandSpec rootSpec, + Set requestedModules, + ModuleType moduleTypeFilter, + boolean runnableOnly, + boolean includeHidden) { + var modules = new java.util.LinkedHashSet(); + + FcliCommandSpecHelper.commandTreeStream(rootSpec) + .forEach(spec -> { + if (runnableOnly && !FcliCommandSpecHelper.isRunnable(spec)) { + return; + } + if (!includeHidden && spec.usageMessage().hidden()) { + return; + } + + String qualifiedName = spec.qualifiedName(" "); + String[] parts = qualifiedName.split(" "); + String moduleName = parts.length > 1 ? parts[1] : ""; + String entityName = parts.length > 2 ? parts[2] : ""; + + // Only consider module-level commands: "fcli " + if (moduleName.isEmpty() || !entityName.isEmpty()) { + return; + } + + // No specific base modules requested: include all + if (requestedModules == null || requestedModules.isEmpty()) { + if (matchesModuleType(spec, moduleTypeFilter)) { + modules.add(moduleName); + } + return; + } + + // Directly requested module + if (requestedModules.contains(moduleName)) { + if (matchesModuleType(spec, moduleTypeFilter)) { + modules.add(moduleName); + } + return; + } + + // Indirectly related via @RelatedModules on the command class + RelatedModules related = getRelatedModulesAnnotation(spec); + if (related != null) { + for (String base : related.value()) { + if (requestedModules.contains(base)) { + if (matchesModuleType(spec, moduleTypeFilter)) { + modules.add(moduleName); + } + break; + } + } + } + }); + + ArrayNode modulesArray = objectMapper.createArrayNode(); + modules.forEach(modulesArray::add); + + ObjectNode result = objectMapper.createObjectNode(); + result.set("modules", modulesArray); + result.put("count", modules.size()); + return result; + } + + private RelatedModules getRelatedModulesAnnotation(CommandSpec spec) { + Object userObject = FcliCommandSpecHelper.userObject(spec); + if (userObject == null) { + return null; + } + return userObject.getClass().getAnnotation(RelatedModules.class); + } + + private ModuleType parseRequestedModuleType(String moduleTypeParam) { + if (moduleTypeParam == null || moduleTypeParam.isBlank()) { + return null; + } + String v = moduleTypeParam.trim(); + if (v.equalsIgnoreCase("product")) { + return ModuleType.PRODUCT; + } + if (v.equalsIgnoreCase("other") || v.equalsIgnoreCase("others")) { + return ModuleType.OTHER; + } + return null; // Unknown value: ignore filter + } + + private boolean matchesModuleType(CommandSpec spec, ModuleType moduleTypeFilter) { + if (moduleTypeFilter == null) { + return true; // no filter → accept all + } + ProductModule pm = getProductModuleAnnotation(spec); + // Treat unannotated modules as OTHER by default + ModuleType effectiveType = pm != null ? pm.value() : ModuleType.OTHER; + return effectiveType == moduleTypeFilter; + } + + private ProductModule getProductModuleAnnotation(CommandSpec spec) { + Object userObject = FcliCommandSpecHelper.userObject(spec); + if (userObject == null) { + return null; + } + return userObject.getClass().getAnnotation(ProductModule.class); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java new file mode 100644 index 0000000000..90036217a3 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.util.FcliBuildProperties; + +import lombok.RequiredArgsConstructor; + +/** + * RPC method handler for getting fcli version information. + * + * Method: fcli.version + * Params: none + * + * Returns: + * - version (string): The fcli version + * - buildDate (string): The build date + * - actionSchemaVersion (string): The action schema version + * + * @author Ruud Senden + */ +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliVersion implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var props = FcliBuildProperties.INSTANCE; + + ObjectNode result = objectMapper.createObjectNode(); + result.put("version", props.getFcliVersion()); + result.put("buildDate", props.getFcliBuildDateString()); + result.put("actionSchemaVersion", props.getFcliActionSchemaVersion()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java new file mode 100644 index 0000000000..4876c93134 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java @@ -0,0 +1,115 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for FoD session login. + * + * Method: fcli.fod.login + * Params: + * - url (string, required): FoD URL (e.g., "https://ams.fortify.com") + * - client-id (string, optional): API client ID for client credentials auth + * - client-secret (string, optional): API client secret for client credentials auth + * - user (string, optional): Username for user/password auth + * - password (string, optional): Password for user/password auth + * - tenant (string, optional): Tenant name (required for user/password auth) + * - insecure (boolean, optional): Allow insecure connections + * + * Authentication requires either (client-id + client-secret) or (user + password + tenant). + * + * Returns: + * - success (boolean): Whether login was successful + * - sessionName (string): The session name created + * - product (string): "fod" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFodLogin implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("url")) { + throw RpcMethodException.invalidParams("'url' parameter is required"); + } + + var loginArgs = buildLoginArgs(params); + + log.debug("FoD login with args: {}", loginArgs.replaceAll("(--password|--client-secret)\\s+\\S+", "$1 ***")); + + return sessionManager.executeLogin(ProductType.FOD, loginArgs); + } + + private String buildLoginArgs(JsonNode params) throws RpcMethodException { + var sb = new StringBuilder(); + + // URL is required + sb.append("--url ").append(quoteValue(params.get("url").asText())).append(" "); + + // Authentication - at least one method required + boolean hasAuth = false; + + if (params.has("client-id") && params.has("client-secret")) { + sb.append("--client-id ").append(quoteValue(params.get("client-id").asText())).append(" "); + sb.append("--client-secret ").append(quoteValue(params.get("client-secret").asText())).append(" "); + hasAuth = true; + } + + if (params.has("user") && params.has("password")) { + if (!params.has("tenant")) { + throw RpcMethodException.invalidParams( + "FoD user/password login requires 'tenant' parameter"); + } + sb.append("--user ").append(quoteValue(params.get("user").asText())).append(" "); + sb.append("--password ").append(quoteValue(params.get("password").asText())).append(" "); + sb.append("--tenant ").append(quoteValue(params.get("tenant").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "FoD login requires either (client-id + client-secret) or (user + password + tenant)"); + } + + // Optional parameters + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { + sb.append("-k "); + } + + return sb.toString().trim(); + } + + /** + * Quote a value for use in fcli command arguments. + * Always quotes the value to ensure special characters are handled correctly. + * The value is placed in double quotes with any internal quotes escaped. + */ + private String quoteValue(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + // Escape any double quotes in the value and wrap in double quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java new file mode 100644 index 0000000000..0f70ef24f2 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for FoD session logout. + * + * Method: fcli.fod.logout + * Params: none required + * + * Returns: + * - success (boolean): Whether logout was successful + * - sessionName (string): The session name that was logged out + * - product (string): "fod" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFodLogout implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + log.debug("FoD logout"); + return sessionManager.executeLogout(ProductType.FOD); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java new file mode 100644 index 0000000000..2846037122 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java @@ -0,0 +1,84 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + +/** + * RPC method handler for listing available RPC methods. + * + * Method: rpc.listMethods + * Params: none + * + * Returns: + * - methods (array): Array of method descriptors with: + * - name (string): Method name + * - description (string): Method description + * + * @author Ruud Senden + */ +@RequiredArgsConstructor +public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + + private static final Map METHOD_DESCRIPTIONS = new HashMap<>(); + + static { + // Core execution methods + METHOD_DESCRIPTIONS.put("fcli.execute", "Execute an fcli command synchronously and return all results"); + METHOD_DESCRIPTIONS.put("fcli.executeAsync", "Start async fcli command execution, returns cacheKey for paged retrieval"); + METHOD_DESCRIPTIONS.put("fcli.getPage", "Retrieve a page of results from cache by cacheKey"); + METHOD_DESCRIPTIONS.put("fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey"); + METHOD_DESCRIPTIONS.put("fcli.clearCache", "Clear cache entries (specific cacheKey or all)"); + + // Info methods + METHOD_DESCRIPTIONS.put("fcli.listCommands", "List available fcli commands with optional filtering"); + METHOD_DESCRIPTIONS.put("fcli.version", "Get fcli version information"); + METHOD_DESCRIPTIONS.put("rpc.listMethods", "List available RPC methods"); + + // SSC session methods + METHOD_DESCRIPTIONS.put("fcli.ssc.login", "Login to SSC (params: url, user+password or token or ci-token)"); + METHOD_DESCRIPTIONS.put("fcli.ssc.logout", "Logout from SSC session"); + + // FoD session methods + METHOD_DESCRIPTIONS.put("fcli.fod.login", "Login to FoD (params: url, client-id+client-secret or user+password+tenant)"); + METHOD_DESCRIPTIONS.put("fcli.fod.logout", "Logout from FoD session"); + } + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + ArrayNode methods = objectMapper.createArrayNode(); + + for (String methodName : methodHandlers.keySet()) { + ObjectNode method = objectMapper.createObjectNode(); + method.put("name", methodName); + method.put("description", METHOD_DESCRIPTIONS.getOrDefault(methodName, "No description available")); + methods.add(method); + } + + ObjectNode result = objectMapper.createObjectNode(); + result.set("methods", methods); + result.put("count", methods.size()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java new file mode 100644 index 0000000000..4f8ed19557 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java @@ -0,0 +1,122 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for SSC session login. + * + * Method: fcli.ssc.login + * Params: + * - url (string, required): SSC URL + * - user (string, optional): Username for user/password auth + * - password (string, optional): Password for user/password auth + * - token (string, optional): UnifiedLoginToken for token-based auth + * - client-auth-token (string, optional): SC-SAST client auth token + * - sc-sast-url (string, optional): SC-SAST controller URL + * - expire-in (string, optional): Token expiration time (e.g., "1d", "8h") + * - insecure (boolean, optional): Allow insecure connections + * + * At least one auth method must be provided: (user+password) or token. + * + * Returns: + * - success (boolean): Whether login was successful + * - sessionName (string): The session name created + * - product (string): "ssc" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerSscLogin implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("url")) { + throw RpcMethodException.invalidParams("'url' parameter is required"); + } + + var loginArgs = buildLoginArgs(params); + + log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--client-auth-token)\\s+\\S+", "$1 ***")); + + return sessionManager.executeLogin(ProductType.SSC, loginArgs); + } + + private String buildLoginArgs(JsonNode params) throws RpcMethodException { + var sb = new StringBuilder(); + + // URL is required + sb.append("--url ").append(quoteValue(params.get("url").asText())).append(" "); + + // Authentication - at least one method required + boolean hasAuth = false; + + if (params.has("user") && params.has("password")) { + sb.append("--user ").append(quoteValue(params.get("user").asText())).append(" "); + sb.append("--password ").append(quoteValue(params.get("password").asText())).append(" "); + hasAuth = true; + } + + if (params.has("token")) { + sb.append("--token ").append(quoteValue(params.get("token").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "SSC login requires one of: (user + password) or token"); + } + + // Optional parameters + if (params.has("expire-in")) { + sb.append("--expire-in ").append(params.get("expire-in").asText()).append(" "); + } + + if (params.has("client-auth-token")) { + sb.append("--client-auth-token ").append(quoteValue(params.get("client-auth-token").asText())).append(" "); + } + + if (params.has("sc-sast-url")) { + sb.append("--sc-sast-url ").append(quoteValue(params.get("sc-sast-url").asText())).append(" "); + } + + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { + sb.append("-k "); + } + + return sb.toString().trim(); + } + + /** + * Quote a value for use in fcli command arguments. + * Always quotes the value to ensure special characters are handled correctly. + * The value is placed in double quotes with any internal quotes escaped. + */ + private String quoteValue(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + // Escape any double quotes in the value and wrap in double quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java new file mode 100644 index 0000000000..aeed4f0e52 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for SSC session logout. + * + * Method: fcli.ssc.logout + * Params: none required + * + * Returns: + * - success (boolean): Whether logout was successful + * - sessionName (string): The session name that was logged out + * - product (string): "ssc" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerSscLogout implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + log.debug("SSC logout"); + return sessionManager.executeLogout(ProductType.SSC); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java new file mode 100644 index 0000000000..300492039a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java @@ -0,0 +1,321 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Manages sessions for the RPC server. This class: + * - Creates unique session names for each product type (SSC, FoD, etc.) + * - Tracks which sessions have been created by the RPC server + * - Auto-discovers which session type is needed for a command + * - Provides session options to be added to commands + * - Logs out all sessions when the server shuts down + * + * The architecture is extensible: new products can be added by registering + * additional product handlers. + * + * @author Ruud Senden + */ +@Slf4j +public final class RpcSessionManager { + + /** + * Supported product types and their session option names. + */ + public enum ProductType { + SSC("--ssc-session", "ssc", "ssc session"), + FOD("--fod-session", "fod", "fod session"), + SC_SAST("--ssc-session", "sc-sast", "ssc session"), // SC-SAST uses SSC session + SC_DAST("--ssc-session", "sc-dast", "ssc session"); // SC-DAST uses SSC session + + @Getter private final String sessionOption; + @Getter private final String commandPrefix; + @Getter private final String sessionCommandPrefix; + + ProductType(String sessionOption, String commandPrefix, String sessionCommandPrefix) { + this.sessionOption = sessionOption; + this.commandPrefix = commandPrefix; + this.sessionCommandPrefix = sessionCommandPrefix; + } + + /** + * Determine the product type from a command string. + */ + public static ProductType fromCommand(String command) { + if (command == null) return null; + var normalizedCmd = command.toLowerCase().replaceFirst("^fcli\\s+", "").trim(); + + // Check specific product prefixes + if (normalizedCmd.startsWith("ssc ")) return SSC; + if (normalizedCmd.startsWith("fod ")) return FOD; + if (normalizedCmd.startsWith("sc-sast ")) return SC_SAST; + if (normalizedCmd.startsWith("sc-dast ")) return SC_DAST; + + return null; + } + + /** + * Get the actual session type for this product (e.g., SC-SAST uses SSC session). + */ + public ProductType getSessionType() { + return switch (this) { + case SC_SAST, SC_DAST -> SSC; + default -> this; + }; + } + } + + private final ObjectMapper objectMapper; + + // Unique ID for this RPC server instance + private final String instanceId = UUID.randomUUID().toString().substring(0, 8); + + // Session names created by this RPC server (product type -> session name) + private final Map sessionNames = new HashMap<>(); + + // Set of sessions that we've successfully logged in (need to logout on shutdown) + private final Set activeSessions = new LinkedHashSet<>(); + + // Registry of RPC method handlers for session login (product -> handler) + private final Map loginHandlers = new LinkedHashMap<>(); + + // Registry of RPC method handlers for session logout (product -> handler) + private final Map logoutHandlers = new LinkedHashMap<>(); + + public RpcSessionManager(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + registerDefaultHandlers(); + } + + private void registerDefaultHandlers() { + // Register SSC session handlers + registerLoginHandler("ssc", new RpcMethodHandlerSscLogin(objectMapper, this)); + registerLogoutHandler("ssc", new RpcMethodHandlerSscLogout(objectMapper, this)); + + // Register FoD session handlers + registerLoginHandler("fod", new RpcMethodHandlerFodLogin(objectMapper, this)); + registerLogoutHandler("fod", new RpcMethodHandlerFodLogout(objectMapper, this)); + } + + /** + * Register a login handler for a product. + */ + public void registerLoginHandler(String product, IRpcMethodHandler handler) { + loginHandlers.put(product.toLowerCase(), handler); + } + + /** + * Register a logout handler for a product. + */ + public void registerLogoutHandler(String product, IRpcMethodHandler handler) { + logoutHandlers.put(product.toLowerCase(), handler); + } + + /** + * Get all login handlers (for registering RPC methods). + */ + public Map getLoginHandlers() { + return Map.copyOf(loginHandlers); + } + + /** + * Get all logout handlers (for registering RPC methods). + */ + public Map getLogoutHandlers() { + return Map.copyOf(logoutHandlers); + } + + /** + * Get the session name for a product type, creating one if needed. + */ + public String getSessionName(ProductType productType) { + // Use the actual session type (e.g., SC-SAST uses SSC session) + var sessionType = productType.getSessionType(); + return sessionNames.computeIfAbsent(sessionType, + pt -> "rpc-" + instanceId + "-" + pt.name().toLowerCase()); + } + + /** + * Get session options to add to a command, based on the command prefix. + * Returns empty map if the command doesn't need a session or if we don't have one. + */ + public Map getSessionOptionsForCommand(String command) { + var productType = ProductType.fromCommand(command); + if (productType == null) { + return Map.of(); + } + + // Use the actual session type + var sessionType = productType.getSessionType(); + + // If we have an active session for this product type, add the option + if (activeSessions.contains(sessionType)) { + var sessionName = sessionNames.get(sessionType); + if (sessionName != null) { + return Map.of(productType.getSessionOption(), sessionName); + } + } + + return Map.of(); + } + + /** + * Execute login command and track the session. + */ + public JsonNode executeLogin(ProductType productType, String loginArgs) { + var sessionName = getSessionName(productType); + var loginCmd = buildLoginCommand(productType, sessionName, loginArgs); + + log.info("RPC session login: {} (session: {})", productType, sessionName); + + var result = FcliCommandExecutorFactory.builder() + .cmd(loginCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("product", productType.name().toLowerCase().replace("_", "-")); + response.put("sessionName", sessionName); + + if (result.getExitCode() == 0) { + activeSessions.add(productType.getSessionType()); + response.put("success", true); + response.put("message", "Successfully logged in to " + productType); + log.info("RPC session login successful: {}", sessionName); + } else { + response.put("success", false); + response.put("message", "Login failed: " + result.getErr()); + response.put("stderr", result.getErr()); + log.error("RPC session login failed: {} - {}", sessionName, result.getErr()); + } + + return response; + } + + /** + * Execute logout command for a product. + */ + public JsonNode executeLogout(ProductType productType) { + var sessionType = productType.getSessionType(); + var sessionName = sessionNames.get(sessionType); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("product", productType.name().toLowerCase().replace("_", "-")); + + if (sessionName == null || !activeSessions.contains(sessionType)) { + response.put("success", true); + response.put("message", "No active session to logout"); + return response; + } + + var logoutCmd = buildLogoutCommand(productType, sessionName); + + log.info("RPC session logout: {} (session: {})", productType, sessionName); + + var result = FcliCommandExecutorFactory.builder() + .cmd(logoutCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + response.put("sessionName", sessionName); + + if (result.getExitCode() == 0) { + activeSessions.remove(sessionType); + response.put("success", true); + response.put("message", "Successfully logged out from " + productType); + log.info("RPC session logout successful: {}", sessionName); + } else { + response.put("success", false); + response.put("message", "Logout failed: " + result.getErr()); + log.warn("RPC session logout failed: {} - {}", sessionName, result.getErr()); + } + + return response; + } + + /** + * Logout from all sessions created by this RPC server. + * Called on server shutdown. + */ + public void logoutAll() { + log.info("Logging out all RPC sessions..."); + + // Iterate through activeSessions directly to avoid duplicate logout attempts + // (e.g., SC_SAST and SC_DAST share SSC session type) + for (var sessionType : Set.copyOf(activeSessions)) { + try { + executeLogout(sessionType); + } catch (Exception e) { + log.warn("Failed to logout session for {}: {}", sessionType, e.getMessage()); + } + } + + activeSessions.clear(); + sessionNames.clear(); + log.info("All RPC sessions logged out"); + } + + /** + * Get list of active sessions as JSON. + */ + public JsonNode getActiveSessions() { + ArrayNode sessions = objectMapper.createArrayNode(); + for (var productType : activeSessions) { + ObjectNode session = objectMapper.createObjectNode(); + session.put("product", productType.name().toLowerCase().replace("_", "-")); + session.put("sessionName", sessionNames.get(productType)); + sessions.add(session); + } + return sessions; + } + + /** + * Check if a session is active for a product type. + */ + public boolean hasActiveSession(ProductType productType) { + return activeSessions.contains(productType.getSessionType()); + } + + private String buildLoginCommand(ProductType productType, String sessionName, String loginArgs) { + // Session name is generated internally (rpc-{uuid}-{product}) and is safe + // loginArgs are pre-quoted by the login handlers + var baseCmd = productType.getSessionCommandPrefix() + " login"; + return String.format("%s %s %s", baseCmd, sessionName, loginArgs != null ? loginArgs : "").trim(); + } + + private String buildLogoutCommand(ProductType productType, String sessionName) { + // Session name is generated internally and is safe + var baseCmd = productType.getSessionCommandPrefix() + " logout"; + return String.format("%s %s", baseCmd, sessionName); + } +} diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 7da5271b2b..83e95e1d24 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -106,6 +106,98 @@ fcli.util.mcp-server.start.progress-threads = Number of threads used for updatin fcli.util.mcp-server.start.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. fcli.util.mcp-server.start.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). +# fcli util rpc-server +fcli.util.rpc-server.usage.header = (PREVIEW) Manage fcli JSON-RPC server for IDE plugin integration +fcli.util.rpc-server.start.usage.header = (PREVIEW) Start fcli JSON-RPC server for IDE plugin integration +fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides a simple JSON-RPC 2.0 interface \ + for IDE plugins and other tools to interact with Fortify products through fcli. Unlike the MCP server which is \ + designed for LLM integration, the RPC server exposes a smaller set of general-purpose methods suitable for \ + programmatic access from IDE plugins.%n%n\ + The server reads JSON-RPC requests from stdin and writes responses to stdout, one JSON object per line. \ + Sessions are automatically logged out when the server terminates.%n%n\ + Available RPC methods:%n\ + %n SESSION METHODS (per product):\ + %n\ + %n - fcli.ssc.login: Login to SSC\ + %n Parameters:\ + %n - url (string, required): SSC URL\ + %n - user (string): Username for user/password auth\ + %n - password (string): Password for user/password auth\ + %n - token (string): UnifiedLoginToken or CIToken\ + %n - client-auth-token (string, optional): SC-SAST client auth token\ + %n - sc-sast-url (string, optional): SC-SAST controller URL\ + %n - expire-in (string): Token expiration (e.g., "1d", "8h")\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires one of (user+password) or token\ + %n\ + %n - fcli.ssc.logout: Logout from SSC session\ + %n\ + %n - fcli.fod.login: Login to FoD\ + %n Parameters:\ + %n - url (string, required): FoD URL (e.g., "https://ams.fortify.com")\ + %n - client-id (string): API client ID\ + %n - client-secret (string): API client secret\ + %n - user (string): Username\ + %n - password (string): Password\ + %n - tenant (string): Tenant name (required for user/password)\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires either (client-id+client-secret) or (user+password+tenant)\ + %n\ + %n - fcli.fod.logout: Logout from FoD session\ + %n\ + %n EXECUTION METHODS:\ + %n\ + %n - fcli.execute: Execute an fcli command synchronously and return ALL results\ + %n Parameters:\ + %n - command (string, required): The fcli command to execute (e.g., "ssc appversion list")\ + %n - collectRecords (boolean, optional): If true, collect structured records instead of stdout\ + %n Note: For large datasets, use fcli.executeAsync + fcli.getPage instead\ + %n\ + %n - fcli.executeAsync: Start async command execution, returns cacheKey for paged retrieval\ + %n Parameters:\ + %n - command (string, required): The fcli command to execute\ + %n Returns: cacheKey to use with fcli.getPage\ + %n\ + %n - fcli.getPage: Retrieve a page of results from cache\ + %n Parameters:\ + %n - cacheKey (string, required): Cache key from fcli.executeAsync\ + %n - offset (integer, optional): Start offset (default: 0)\ + %n - limit (integer, optional): Max records per page (default: 100)\ + %n - wait (boolean, optional): Wait for completion if still loading (default: false)\ + %n - waitTimeoutMs (integer, optional): Max wait time in ms (default: 30000)\ + %n\ + %n - fcli.cancelCollection: Cancel an in-progress async collection\ + %n Parameters:\ + %n - cacheKey (string, required): Cache key to cancel\ + %n\ + %n - fcli.clearCache: Clear cache entries\ + %n Parameters:\ + %n - cacheKey (string, optional): Specific key to clear, or omit to clear all\ + %n\ + %n INFO METHODS:\ + %n\ + %n - fcli.listCommands: List available fcli commands with optional filtering\ + %n Parameters:\ + %n - module (string, optional): Filter by module (e.g., "ssc", "fod")\ + %n - runnableOnly (boolean, optional): If true, only return runnable (leaf) commands\ + %n - includeHidden (boolean, optional): If true, include hidden commands\ + %n\ + %n - fcli.version: Get fcli version information\ + %n Parameters: none\ + %n\ + %n - rpc.listMethods: List available RPC methods\ + %n Parameters: none\ + %n%n\ + Typical workflow:%n\ + 1. Call fcli.ssc.login or fcli.fod.login with credentials%n\ + 2. Execute commands via fcli.execute or fcli.executeAsync%n\ + 3. Session options are automatically added to commands%n\ + 4. Sessions are logged out automatically when RPC server terminates%n%n\ + Example JSON-RPC requests:%n\ + %n{"jsonrpc":"2.0","method":"fcli.ssc.login","params":{"url":"https://ssc.example.com","token":"mytoken"},"id":1}\ + %n{"jsonrpc":"2.0","method":"fcli.execute","params":{"command":"ssc appversion list","collectRecords":true},"id":2}\ + %n{"jsonrpc":"2.0","method":"fcli.ssc.logout","id":3} + # fcli util sample-data fcli.util.sample-data.usage.header = (INTERNAL) Generate sample data fcli.util.sample-data.usage.description = These commands generate and output a fixed set of sample data \ diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java new file mode 100644 index 0000000000..d73d86ba52 --- /dev/null +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -0,0 +1,497 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.JsonRpcServer; + +/** + * Unit tests for {@link JsonRpcServer}. Tests the JSON-RPC 2.0 protocol handling + * including request parsing, response generation, and error handling. + * + * @author Ruud Senden + */ +class JsonRpcServerTest { + + private JsonRpcServer server; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + server = new JsonRpcServer(objectMapper); + } + + @Test + void shouldReturnParseErrorForInvalidJson() throws Exception { + // Act + String response = server.processRequest("not valid json"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32700, node.get("error").get("code").asInt()); + assertNull(node.get("result")); + } + + @Test + void shouldReturnInvalidRequestForMissingJsonrpcVersion() throws Exception { + // Act + String response = server.processRequest("{\"method\":\"test\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnInvalidRequestForWrongJsonrpcVersion() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"1.0\",\"method\":\"test\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnMethodNotFoundForUnknownMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"unknown.method\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32601, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("unknown.method")); + assertEquals(1, node.get("id").asInt()); + } + + @Test + void shouldReturnNullForNotification() throws Exception { + // Notification = request without id + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\"}"); + + // Assert - notifications should not return a response + assertNull(response); + } + + @Test + void shouldExecuteFcliVersionMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":42}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + assertEquals(42, node.get("id").asInt()); + + // Check result contains version info + var result = node.get("result"); + assertTrue(result.has("version")); + assertTrue(result.has("buildDate")); + assertTrue(result.has("actionSchemaVersion")); + } + + @Test + void shouldExecuteRpcListMethodsMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + + // Check result contains methods list + var result = node.get("result"); + assertTrue(result.has("methods")); + assertTrue(result.get("methods").isArray()); + assertTrue(result.get("methods").size() >= 4); // At least our 4 default methods + assertTrue(result.has("count")); + } + + @Test + void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("command")); + } + + @Test + void shouldReturnInvalidParamsForZeroLimit() throws Exception { + // Test limit validation in fcli.getPage + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"limit\":0},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("limit")); + } + + @Test + void shouldReturnInvalidParamsForNegativeOffset() throws Exception { + // Test offset validation in fcli.getPage + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"offset\":-5},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("offset")); + } + + @Test + void shouldPreserveRequestIdInResponse() throws Exception { + // Test with string id + String response1 = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":\"string-id\"}"); + assertNotNull(response1); + var node1 = objectMapper.readTree(response1); + assertEquals("string-id", node1.get("id").asText()); + + // Test with numeric id + String response2 = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":999}"); + assertNotNull(response2); + var node2 = objectMapper.readTree(response2); + assertEquals(999, node2.get("id").asInt()); + } + + @Test + void shouldHandleBatchRequest() throws Exception { + // Act + String response = server.processRequest( + "[{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":2}]" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertTrue(node.isArray()); + assertEquals(2, node.size()); + + // Both responses should be successful + for (var responseNode : node) { + assertEquals("2.0", responseNode.get("jsonrpc").asText()); + assertNotNull(responseNode.get("result")); + assertNull(responseNode.get("error")); + } + } + + @Test + void shouldReturnInvalidRequestForEmptyBatch() throws Exception { + // Act + String response = server.processRequest("[]"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldHandleNullId() throws Exception { + // Act - id is explicitly null (this is a notification) + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":null}"); + + // Assert - no response for notifications + assertNull(response); + } + + @Test + void shouldHandleRequestWithNullParams() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"params\":null,\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + } + + @Test + void shouldReturnErrorForListCommandsWithoutAppContext() throws Exception { + // Note: fcli.listCommands requires the full fcli command tree to be initialized, + // which isn't available in unit tests. This test verifies that the method + // returns an error response rather than crashing. + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.listCommands\",\"params\":{},\"id\":1}" + ); + + // Either we get an error (expected in unit test context) or a result (if running in full context) + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + // In unit test context, we expect an error since the command tree isn't initialized + // but the important thing is that it doesn't crash + assertTrue(node.has("error") || node.has("result")); + } + + @Test + void shouldReturnCacheKeyForExecuteAsync() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.executeAsync\",\"params\":{\"command\":\"util sample-data list\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + + var result = node.get("result"); + assertTrue(result.has("cacheKey")); + assertNotNull(result.get("cacheKey").asText()); + assertEquals("started", result.get("status").asText()); + } + + @Test + void shouldReturnInvalidParamsForExecuteAsyncWithoutCommand() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.executeAsync\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnNotFoundForGetPageWithInvalidCacheKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"non-existent-key\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals("not_found", node.get("result").get("status").asText()); + } + + @Test + void shouldReturnInvalidParamsForGetPageWithoutCacheKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldHandleCancelCollectionForNonExistentKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.cancelCollection\",\"params\":{\"cacheKey\":\"non-existent-key\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals(false, node.get("result").get("success").asBoolean()); + } + + @Test + void shouldHandleClearCacheAll() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.clearCache\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals(true, node.get("result").get("success").asBoolean()); + assertNotNull(node.get("result").get("stats")); + } + + @Test + void shouldListAllNewMethodsInRpcListMethods() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + + var methods = node.get("result").get("methods"); + assertTrue(methods.isArray()); + // Verify minimum expected methods - don't hardcode exact count for maintainability + assertTrue(methods.size() >= 8, "Should have at least 8 methods including async ones"); + + // Verify new methods are present + boolean hasExecuteAsync = false; + boolean hasGetPage = false; + boolean hasCancelCollection = false; + boolean hasClearCache = false; + + for (var method : methods) { + String name = method.get("name").asText(); + if ("fcli.executeAsync".equals(name)) hasExecuteAsync = true; + if ("fcli.getPage".equals(name)) hasGetPage = true; + if ("fcli.cancelCollection".equals(name)) hasCancelCollection = true; + if ("fcli.clearCache".equals(name)) hasClearCache = true; + } + + assertTrue(hasExecuteAsync, "fcli.executeAsync method should be present"); + assertTrue(hasGetPage, "fcli.getPage method should be present"); + assertTrue(hasCancelCollection, "fcli.cancelCollection method should be present"); + assertTrue(hasClearCache, "fcli.clearCache method should be present"); + } + + @Test + void shouldListSessionMethodsInRpcListMethods() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + + var methods = node.get("result").get("methods"); + assertTrue(methods.isArray()); + // Verify we have at least 12 methods (8 core + 4 session methods) + assertTrue(methods.size() >= 12, "Should have at least 12 methods including session ones"); + + // Verify session methods are present + boolean hasSscLogin = false; + boolean hasSscLogout = false; + boolean hasFodLogin = false; + boolean hasFodLogout = false; + + for (var method : methods) { + String name = method.get("name").asText(); + if ("fcli.ssc.login".equals(name)) hasSscLogin = true; + if ("fcli.ssc.logout".equals(name)) hasSscLogout = true; + if ("fcli.fod.login".equals(name)) hasFodLogin = true; + if ("fcli.fod.logout".equals(name)) hasFodLogout = true; + } + + assertTrue(hasSscLogin, "fcli.ssc.login method should be present"); + assertTrue(hasSscLogout, "fcli.ssc.logout method should be present"); + assertTrue(hasFodLogin, "fcli.fod.login method should be present"); + assertTrue(hasFodLogout, "fcli.fod.logout method should be present"); + } + + @Test + void shouldReturnInvalidParamsForSscLoginWithoutUrl() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.ssc.login\",\"params\":{\"token\":\"test\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("url")); + } + + @Test + void shouldReturnInvalidParamsForSscLoginWithoutAuth() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.ssc.login\",\"params\":{\"url\":\"https://ssc.example.com\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnInvalidParamsForFodLoginWithoutUrl() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.fod.login\",\"params\":{\"client-id\":\"test\",\"client-secret\":\"test\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("url")); + } + + @Test + void shouldReturnInvalidParamsForFodLoginWithoutAuth() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.fod.login\",\"params\":{\"url\":\"https://ams.fortify.com\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } +}