A Spring Boot library that turns NetScope-Server gRPC endpoints into plain Java method calls. Declare an annotated interface, add the dependency, configure a server — the library does the rest.
- Prerequisites
- Installation
- Quick Start
- Configuration
- Authentication
- Return Types
- Overloaded Methods & Parameter Types
- Remote Field Writes — @SetAttribute
- Server Introspection — GetDocs
- Imperative Template API
- Advanced Streaming
- Error Handling
- Reactive Support
- How It Works
| Requirement | Version |
|---|---|
| Java | 21+ |
| Spring Boot | 3.2.x+ |
| NetScope-Server | 1.0.0 (on the remote side) |
Add the dependency to your pom.xml:
<dependency>
<groupId>org.fractalx</groupId>
<artifactId>netscope-client</artifactId>
<version>1.0.0</version>
</dependency>Reactive support (
Mono/Fluxreturn types) is optional. Addspring-boot-starter-webfluxif you need it — the library detects it automatically at runtime.
<!-- Optional: only needed for Mono / Flux return types -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>Add @EnableNetScopeClient to your main application class. Point it at the package that contains your client interfaces.
@SpringBootApplication@EnableNetScopeClient(basePackages = "com.myapp.clients")
publicclassMyApplication {
publicstaticvoidmain(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}In application.yml, register each remote server under netscope.client.servers:
netscope:
client:
servers:
inventory-service:
host: localhostport: 9090Create an interface in the scanned package. Annotate it with @NetScopeClient, specifying:
server— the key fromnetscope.client.serversbeanName— the Spring bean name on the remote server
Method names must match the public methods exposed by that remote bean.
packagecom.myapp.clients;
importorg.fractalx.netscope.client.annotation.NetScopeClient;
@NetScopeClient(server = "inventory-service", beanName = "InventoryService")
publicinterfaceInventoryClient {
intgetStock(StringproductId);
ProductgetProduct(StringproductId);
List<Product> listProducts();
}The library auto-registers the interface as a Spring bean. Inject it anywhere and call it like a local service.
@ServicepublicclassOrderService {
@AutowiredprivateInventoryClientinventory;
publicvoidplaceOrder(StringproductId, intquantity) {
intavailable = inventory.getStock(productId);
if (available < quantity) {
thrownewIllegalStateException("Insufficient stock");
}
// ...
}
}All settings live under the netscope.client prefix.
netscope:
client:
servers:
# A named server entry — referenced by @NetScopeClient(server = "...")inventory-service:
host: localhost # default: localhostport: 9090# default: 9090auth:
type: NONE # NONE | API_KEY | OAUTH (default: NONE)auth-service:
host: auth.internalport: 9091auth:
type: OAUTHtoken-provider: myTokenProviderBean # name of a Supplier<String> Spring beanbilling-service:
host: billing.internalport: 9092auth:
type: API_KEYapi-key: s3cr3t-k3yYou can hardcode host and port directly on the annotation when you do not want a named config entry. Auth can also be specified inline — see Inline auth.
// No auth (default)@NetScopeClient(host = "localhost", port = 9090, beanName = "InventoryService")
publicinterfaceInventoryClient { ... }
// With inline API key@NetScopeClient(host = "metrics.internal", port = 9090,
authType = NetScopeClientConfig.AuthType.API_KEY, apiKey = "my-key",
beanName = "MetricsService")
publicinterfaceMetricsClient { ... }When both
host/portandserverare provided,host/porttake precedence.
Configure auth under the server's auth block in application.yml.
auth:
type: NONEauth:
type: API_KEYapi-key: my-secret-keyThe library attaches x-api-key: <value> to every gRPC request targeting that server.
auth:
type: OAUTHtoken-provider: myTokenProviderBeantoken-provider must be the name of a Spring bean that implements Supplier<String>. The library calls it before each request and attaches authorization: Bearer <token>.
@Component("myTokenProviderBean")
publicclassMyTokenProviderimplementsSupplier<String> {
@OverridepublicStringget() {
returnfetchAccessToken(); // fetch / refresh a valid JWT here
}
}When using host/port directly on @NetScopeClient, configure auth with the annotation fields instead of YAML. This is useful for one-off or test clients that don't need a named server entry.
// Inline API key@NetScopeClient(
host = "secure.internal", port = 9090,
authType = NetScopeClientConfig.AuthType.API_KEY,
apiKey = "my-api-key",
beanName = "SecureService"
)
publicinterfaceSecureClient {
StringgetData();
}
// Inline OAuth (token fetched from a Supplier<String> Spring bean)@NetScopeClient(
host = "auth.internal", port = 9090,
authType = NetScopeClientConfig.AuthType.OAUTH,
tokenProvider = "myTokenProviderBean",
beanName = "AuthService"
)
publicinterfaceAuthClient {
StringgetProfile();
}Two clients pointing at the same
host:portbut with different auth settings each get their own independent gRPC channel.
NetScope Client supports five return type categories on client interface methods:
| Return type | Behaviour |
|---|---|
T (any type) | Blocking call; result deserialized to T |
void | Blocking call; result discarded |
Mono<T> | Non-blocking; gRPC call fires on subscription |
Flux<T> | Streaming; each server response emitted as an element |
CompletableFuture<T> | Async; wraps the blocking call via supplyAsync |
@NetScopeClient(server = "inventory-service", beanName = "InventoryService")
publicinterfaceInventoryClient {
// BlockingintgetStock(StringproductId);
// Async (no Reactor required)CompletableFuture<Integer> getStockAsync(StringproductId);
// Reactive (requires spring-boot-starter-webflux)Mono<Integer> getStockMono(StringproductId);
Flux<StockEvent> streamStockEvents(StringproductId);
}NetScope-Server supports overloaded methods — multiple methods sharing the same name but with different parameter types. The server resolves the right overload using the parameter_types field of each request.
The client automatically populates parameter_types from the Java method signature when using the declarative proxy interface. No extra configuration is needed.
// Server exposes two overloads of "process":// String process(String id)// String process(int id)@NetScopeClient(server = "order-service", beanName = "OrderService")
publicinterfaceOrderClient {
Stringprocess(Stringid); // sends parameter_types = ["String"]Stringprocess(intid); // sends parameter_types = ["int"]
}The proxy derives type names using Class.getSimpleName() — "String", "int", "List", etc. — which matches the format the server's overload cache expects.
When using NetScopeTemplate directly, use withParameterTypes() to specify type names explicitly:
// Calls process(String) specificallyStringresult = netScope
.server("order-service")
.bean("OrderService")
.withParameterTypes("String")
.invoke("process", String.class, "order-42");
// Calls process(int) specificallyStringresult = netScope
.server("order-service")
.bean("OrderService")
.withParameterTypes("int")
.invoke("process", String.class, 42);withParameterTypes() returns a new BeanStep — it does not mutate the original, so the same BeanStep can be reused safely.
NetScope-Server can expose Spring bean fields (not just methods) as network attributes. Reading a field works through a normal invoke() call. Writing a field uses the dedicated SetAttribute gRPC RPC, which also returns the previous value of the field.
Annotate a method with @SetAttribute to route it through the SetAttribute RPC:
importorg.fractalx.netscope.client.annotation.SetAttribute;
@NetScopeClient(server = "inventory-service", beanName = "InventoryService")
publicinterfaceInventoryClient {
// Read the field via InvokeMethod (works as a regular method call)intgetStockLevel();
// Write the field via SetAttribute; annotation value = remote field name@SetAttribute("stockLevel")
intupdateStockLevel(intnewLevel); // returns previous value// When @SetAttribute has no value, the Java method name is used as the field name@SetAttributeStringdescription(StringnewDescription);
}Rules:
- The first method parameter is used as the new field value.
- The return type receives the field's previous value (use
voidto discard it). - The remote field must be exposed with
@NetworkPublicor@NetworkSecuredon the server. - Writing a
finalfield is rejected by the server withFAILED_PRECONDITION.
// Untyped — returns Object (the previous value)Objectprev = netScope
.server("inventory-service")
.bean("InventoryService")
.setAttribute("stockLevel", 500);
// Typed — previous value deserialized to IntegerIntegerprev = netScope
.server("inventory-service")
.bean("InventoryService")
.setAttribute("stockLevel", 500, Integer.class);Call getDocs() on a ServerStep to retrieve live documentation for every method and field registered on the remote NetScope server:
importorg.fractalx.netscope.client.grpc.proto.DocsResponse;
importorg.fractalx.netscope.client.grpc.proto.MethodInfo;
DocsResponsedocs = netScope.server("inventory-service").getDocs();
for (MethodInfoinfo : docs.getMethodsList()) {
System.out.printf(
"[%s] %s.%s(%s) → %s secured=%b%n",
info.getKind(),
info.getBeanName(),
info.getMemberName(),
info.getParametersList().stream()
.map(p -> p.getType() + " " + p.getName())
.collect(Collectors.joining(", ")),
info.getReturnType(),
info.getSecured()
);
}DocsResponse is the protobuf-generated type from netscope.proto. Each MethodInfo entry includes:
| Field | Description |
|---|---|
beanName | Remote Spring bean name |
memberName | Method or field name |
kind | METHOD or FIELD |
parameters | Name, type, index of each parameter |
returnType | Simple class name of the return type |
secured | Whether authentication is required |
writeable | Whether the field can be written (fields only) |
isStatic | Whether the member is static |
isFinal | Whether the member is final |
For dynamic or ad-hoc calls where you do not want to declare a typed interface, inject NetScopeTemplate directly:
@AutowiredprivateNetScopeTemplatenetScope;
// Typed callintstock = netScope
.server("inventory-service")
.bean("InventoryService")
.invoke("getStock", Integer.class, "SKU-001");
// Untyped call (returns Object)Objectresult = netScope
.server("inventory-service")
.bean("InventoryService")
.invoke("getStock", "SKU-001");
// Generic return type (e.g. List<Product>)TypelistType = newTypeToken<List<Product>>(){}.getType();
List<Product> products = (List<Product>) netScope
.server("inventory-service")
.bean("InventoryService")
.invoke("listProducts", listType);
// With explicit parameter types for overload disambiguationStringresult = netScope
.server("order-service")
.bean("OrderService")
.withParameterTypes("String")
.invoke("process", String.class, "order-42");
// Inline host/port with authNetScopeClientConfig.AuthConfigauth = newNetScopeClientConfig.AuthConfig();
auth.setType(NetScopeClientConfig.AuthType.API_KEY);
auth.setApiKey("my-key");
intval = netScope
.server("localhost", 9090, auth)
.bean("MetricsService")
.invoke("getCpuUsage", Integer.class);
// Write a remote fieldIntegerprev = netScope
.server("inventory-service")
.bean("InventoryService")
.setAttribute("stockLevel", 500, Integer.class);
// Introspect a serverDocsResponsedocs = netScope.server("inventory-service").getDocs();Send multiple method invocations over one persistent gRPC stream connection and receive all responses as a Flux<T>. This avoids the per-call connection overhead for high-frequency scenarios.
importorg.fractalx.netscope.client.core.MethodCall;
@AutowiredprivateNetScopeTemplatenetScope;
Flux<Integer> stocks = (Flux<Integer>) netScope
.server("inventory-service")
.bean("InventoryService")
.invokeBatchStream(Integer.class,
MethodCall.of("getStock", "SKU-001"),
MethodCall.of("getStock", "SKU-002"),
MethodCall.of("getStock", "SKU-003"));
stocks.subscribe(System.out::println);A MethodCall can optionally override the default bean name for cross-bean invocations within the same stream:
MethodCall.of("getStock", "SKU-001") // uses BeanStep default beanMethodCall.of("AnotherBean", "method", arg1, arg2) // overrides bean for this callinvokeBatchStream returns a cold Flux<T> — the gRPC stream opens on subscription.
For scenarios where you need to send an unbounded or dynamically-determined number of requests over a single long-lived connection, use BidiStreamSession:
// 1. Open a session (gRPC stream opened immediately)NetScopeTemplate.BidiStreamSession<String> session =
netScope.server("inventory-service").openBidiStream("InventoryService", String.class);
// 2. Subscribe to the response Flux BEFORE sending any requests
(session.responseFlux() asFlux<String>)
.subscribe(
result -> System.out.println("Response: " + result),
error -> log.error("Stream error", error),
() -> System.out.println("Stream complete")
);
// 3. Send requests one at a time (fluent chaining supported)session.send("getStock", "SKU-001")
.send("getStock", "SKU-002")
.send("AnotherBean", "methodName", arg1); // override bean per-call// 4. Signal end of inputsession.complete();openBidiStream also accepts optional parameter type hints for exact overload resolution:
// Every request in this session will send parameter_types = ["String"]varsession = netScope.server("order-service")
.openBidiStream("OrderService", String.class, "String");Important: Subscribe to
responseFlux()before callingsend(), otherwise early responses may be dropped.
All exceptions thrown by the library are unchecked (RuntimeException).
| Exception | When thrown |
|---|---|
NetScopeRemoteException | gRPC call reached the server but failed (wraps StatusRuntimeException) |
NetScopeClientException | Local error before or after the call (serialization, config, missing token, etc.) |
NetScopeRemoteException extends NetScopeClientException, so you can catch both with a single handler.
importorg.fractalx.netscope.client.exception.NetScopeClientException;
importorg.fractalx.netscope.client.exception.NetScopeRemoteException;
try {
intstock = inventory.getStock("SKU-001");
} catch (NetScopeRemoteExceptione) {
// gRPC-level failure — inspect the gRPC status codelog.error("Remote call failed — gRPC status: {}", e.getStatus());
} catch (NetScopeClientExceptione) {
// Local / configuration errorlog.error("Client error", e);
}| Code | Meaning |
|---|---|
NOT_FOUND | Member not found, or no overload matched the given parameter count |
INVALID_ARGUMENT | Ambiguous overload — retry with explicit parameter_types |
UNAUTHENTICATED | Missing or invalid credentials |
FAILED_PRECONDITION | Attempted to write a final field, or called a field as a method |
INTERNAL | Server-side invocation error |
Mono and Flux return types require Project Reactor on the classpath. Add spring-boot-starter-webflux to your dependencies — the library detects it automatically and never fails to start without it.
@NetScopeClient(server = "events-service", beanName = "EventService")
publicinterfaceEventClient {
// Single async resultMono<Event> getEvent(StringeventId);
// Streaming result — each server response is one elementFlux<Event> streamAll();
}eventClient.streamAll()
.filter(e -> e.getSeverity() == Severity.HIGH)
.doOnNext(e -> log.warn("High-severity event: {}", e))
.subscribe();Your code
│
│ calls interface method
▼
JDK Dynamic Proxy (NetScopeInvocationHandler)
│ ├─ extracts parameter type names from reflection → parameter_types
│ ├─ checks @SetAttribute → routes to SetAttribute RPC if present
│ └─ serializes args: Java → Jackson JSON → protobuf Value
▼
gRPC channel (Netty, managed by NetScopeChannelFactory)
│ auth interceptor attaches headers (x-api-key / authorization: Bearer)
│
│ InvokeMethod / SetAttribute / GetDocs / InvokeMethodStream RPC
▼
NetScope-Server
│ resolves overload via exact parameter_types lookup or type inference
│ invokes the target Spring bean method or reads/writes field
│ serializes result → protobuf Value
▼
NetScopeValueConverter (protobuf Value → Jackson → your return type)
│
▼
Your code receives the deserialized result
- Overload resolution:
parameter_typesfrom method reflection are sent with every proxy call, enabling the server's exact overload lookup to fire instead of potentially-ambiguous type inference. - SetAttribute routing: Methods annotated with
@SetAttributebypassInvokeMethodentirely and call theSetAttributeRPC, which returns the previous field value. - Auth-aware channel caching: Channels are cached by
host:port:authType:credential, so two clients pointing at the same address with different auth each get isolated channels. - Auto-configuration: beans are registered via
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. All beans are@ConditionalOnMissingBeanso you can override any of them. - Serialization: arguments and results travel as
google.protobuf.Value(JSON-like). Jackson handles Java ↔ JSON;JsonFormathandles JSON ↔ protobuf. - Reactor isolation:
ReactorOperationsis the only class that imports Reactor. It is only instantiated when Reactor is on the classpath, so the library never causesClassNotFoundExceptionwithout it.
Apache License 2.0 — see LICENSE.
- Sathnindu Kottage — @sathninduk
- FractalX Team — https://github.com/project-FractalX