NetScope Server by FractalX lets you expose any Spring bean method or field as a gRPC endpoint by adding a single annotation. Authentication (OAuth 2.0 JWT and/or API key) is handled automatically.
- Expose bean methods and fields over gRPC with a single annotation (
@NetworkPublic/@NetworkSecured) - Dual authentication: OAuth 2.0 JWT (RS256/384/512, ES256/384/512) and/or API key per member
- Multiple API keys supported — rotate keys without downtime
- Read and write field attributes remotely via dedicated RPCs
- Overloaded method support — correct overload inferred automatically from argument types
- Reactive return types —
Mono,Flux, andCompletableFutureunwrapped automatically - Inherited field and method scanning across the full class hierarchy
- Interface method scanning with automatic interface name aliases
- Static and final field awareness
- Bidirectional streaming support
- Live introspection via
GetDocsRPC - Spring Boot auto-configuration — zero setup beyond a single annotation and a port number
- How it works
- Quick Start
- Installation
- Annotating your beans
- Configuration
- Calling the service
- Passing arguments
- Authentication
- Reading and writing fields
- Live introspection (GetDocs)
- gRPC status codes
- OAuth 2.0 provider examples
- Troubleshooting
- Security best practices
Your Spring bean NetScope gRPC caller
───────────────── ──────── ───────────
@NetworkPublic scans beans on startup grpcurl / Java / Python
public String getVersion() → registers endpoint → InvokeMethod("AppService", "getVersion")
validates auth ← "2.0.0"
Three steps to use it:
- Annotate — add
@NetworkPublicor@NetworkSecuredto the methods/fields you want to expose - Configure — set a port and (optionally) your auth provider in
application.yml - Call — use any gRPC client, grpcurl, or the generated stub to invoke your beans remotely
<dependency>
<groupId>org.fractalx</groupId>
<artifactId>netscope-server</artifactId>
<version>1.0.0</version>
</dependency>import org.fractalx.netscope.server.annotation.EnableNetScopeServer;
@SpringBootApplication
@EnableNetScopeServer
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}Spring Boot auto-configuration activates NetScope automatically when the JAR is on the classpath.
@EnableNetScopeServeris an explicit alternative — use it if auto-configuration is not triggering (e.g. in non-Boot Spring applications or custom application contexts).
@Service
public class AppService {
@NetworkPublic(description = "Current version")
public String getVersion() {
return "1.0.0";
}
@NetworkSecured(auth = AuthType.API_KEY, description = "Restart the service")
public String restart() {
// ...
return "restarted";
}
}netscope:
server:
grpc:
port: 9090grpcurl -plaintext \
-d '{"bean_name": "AppService", "member_name": "getVersion"}' \
localhost:9090 netscope.NetScopeService/InvokeMethod{ "result": "1.0.0" }Build and install the library to your local Maven repository:
mvn clean installThen add to your project:
<dependency>
<groupId>org.fractalx</groupId>
<artifactId>netscope-server</artifactId>
<version>1.0.0</version>
</dependency>@Service
public class AppService {
@NetworkPublic(description = "Current app version")
public String getVersion() {
return "1.0.0";
}
@NetworkPublic(description = "Feature flag — readable and writable remotely")
private boolean maintenanceMode = false;
@NetworkPublic(description = "Build ID — readable only because it is final")
public static final String BUILD_ID = "abc123";
}The auth parameter controls which credential type is accepted:
auth value |
Accepted credential |
|---|---|
AuthType.OAUTH |
OAuth 2.0 JWT Bearer token |
AuthType.API_KEY |
API key |
AuthType.BOTH |
Either OAuth or API key |
@Service
public class CustomerService {
@NetworkSecured(auth = AuthType.OAUTH, description = "Look up a customer")
public Customer getCustomer(String customerId) { ... }
@NetworkSecured(auth = AuthType.API_KEY, description = "Delete a customer")
public void deleteCustomer(String customerId) { ... }
@NetworkSecured(auth = AuthType.BOTH, description = "Request counter — readable and writable")
private int requestCount = 0;
}| Target | Behaviour |
|---|---|
| Method | Callable via InvokeMethod |
| Overloaded methods | All overloads registered; correct one is chosen automatically from argument types |
Mono / Flux / CompletableFuture return |
Unwrapped automatically before the result is returned |
| Non-final field | Readable via InvokeMethod, writable via SetAttribute |
| Final field | Readable via InvokeMethod only — write attempts are rejected |
| Static field or method | Supported |
| Inherited field or method | Scanned automatically up the full superclass chain |
| Interface method | Annotate on the interface — implementing classes don't need to repeat it |
If your bean implements a user-defined interface, you can use either the concrete class name or the interface name as bean_name:
public interface CustomerService {
@NetworkPublic
CustomerResDTO getCustomers();
}
@Service
public class CustomerServiceImpl implements CustomerService {
// No need to repeat @NetworkPublic here
public CustomerResDTO getCustomers() { ... }
}# Both work:
"bean_name": "CustomerServiceImpl"
"bean_name": "CustomerService"Startup log confirms the alias was registered:
[method] CustomerServiceImpl.getCustomers → PUBLIC
[alias] CustomerService → CustomerServiceImpl (1 member)
Note:
GetDocsreturns members under the concrete class name only. Standard Java/Spring interfaces (Serializable,ApplicationContextAware, etc.) are never aliased.
netscope:
server:
grpc:
port: 9090
security:
oauth:
enabled: true
issuerUri: https://your-auth-server.com
jwkSetUri: https://your-auth-server.com/.well-known/jwks.json
audiences:
- your-api-audience
api-key:
enabled: true
keys:
- your-secret-api-keyBoth oauth.enabled and api-key.enabled must be set to true explicitly — neither is on by default.
Security enforcement (security.enabled) defaults to true. To disable all authentication for local development, set it to false:
netscope:
server:
security:
enabled: false # dev mode — disables auth checks for all @NetworkSecured endpointsnetscope:
server:
grpc:
enabled: true
port: 9090
maxInboundMessageSize: 4194304 # bytes (default 4 MB)
maxConcurrentCallsPerConnection: 100
keepAliveTime: 300 # seconds
keepAliveTimeout: 20
permitKeepAliveWithoutCalls: false
maxConnectionIdle: 0 # 0 = unlimited
maxConnectionAge: 0
enableReflection: true
security:
oauth:
enabled: true
issuerUri: https://auth.example.com
jwkSetUri: https://auth.example.com/.well-known/jwks.json
audiences:
- https://api.example.com
tokenCacheDuration: 300 # seconds to cache validated tokens
clockSkew: 60 # seconds of allowed clock drift
api-key:
enabled: true
keys:
- your-primary-api-key
- your-secondary-api-key # multiple keys supported for rotationAll four RPCs share the same service:
| RPC | Use for |
|---|---|
InvokeMethod |
Call a method or read a field |
SetAttribute |
Write a value to a non-final field |
GetDocs |
List all exposed members and their signatures |
InvokeMethodStream |
Bidirectional streaming — many requests, many responses |
Arguments are plain JSON. Strings, numbers, booleans, objects, and arrays are written naturally — no type wrappers needed.
# Call a public method
grpcurl -plaintext \
-d '{"bean_name": "AppService", "member_name": "getVersion"}' \
localhost:9090 netscope.NetScopeService/InvokeMethod
# Call a method with arguments (OAuth)
grpcurl -plaintext \
-H 'authorization: Bearer eyJhbGci...' \
-d '{
"bean_name": "CustomerService",
"member_name": "getCustomer",
"arguments": ["CUST001"]
}' localhost:9090 netscope.NetScopeService/InvokeMethod
# Call a method with multiple arguments
grpcurl -plaintext \
-H 'authorization: Bearer eyJhbGci...' \
-d '{
"bean_name": "OrderService",
"member_name": "placeOrder",
"arguments": ["CUST001", 99.99, true]
}' localhost:9090 netscope.NetScopeService/InvokeMethod
# Read all exposed members
grpcurl -plaintext -d '{}' localhost:9090 netscope.NetScopeService/GetDocsManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 9090)
.usePlaintext()
.build();
NetScopeServiceGrpc.NetScopeServiceBlockingStub stub =
NetScopeServiceGrpc.newBlockingStub(channel);
// Attach an OAuth token
Metadata headers = new Metadata();
headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER),
"Bearer " + accessToken);
stub = MetadataUtils.attachHeaders(stub, headers);
// Call a method
InvokeRequest request = InvokeRequest.newBuilder()
.setBeanName("CustomerService")
.setMemberName("getCustomer")
.setArguments(ListValue.newBuilder()
.addValues(Value.newBuilder().setStringValue("CUST001")))
.build();
InvokeResponse response = stub.invokeMethod(request);
System.out.println(response.getResult());
channel.shutdown();import grpc
from google.protobuf import struct_pb2
from netscope_pb2 import InvokeRequest
from netscope_pb2_grpc import NetScopeServiceStub
channel = grpc.insecure_channel('localhost:9090')
stub = NetScopeServiceStub(channel)
metadata = [('authorization', f'Bearer {access_token}')]
request = InvokeRequest(bean_name="CustomerService", member_name="getCustomer")
request.arguments.values.append(struct_pb2.Value(string_value="CUST001"))
response = stub.InvokeMethod(request, metadata=metadata)
print(response.result)Send multiple requests over a single connection and receive responses as they complete.
NetScopeServiceGrpc.NetScopeServiceStub asyncStub = NetScopeServiceGrpc.newStub(channel);
StreamObserver<InvokeRequest> requestStream =
asyncStub.invokeMethodStream(new StreamObserver<>() {
@Override public void onNext(InvokeResponse r) { System.out.println(r.getResult()); }
@Override public void onError(Throwable t) { t.printStackTrace(); }
@Override public void onCompleted() { System.out.println("done"); }
});
for (int i = 0; i < 10; i++) {
requestStream.onNext(InvokeRequest.newBuilder()
.setBeanName("DataService")
.setMemberName("processItem")
.setArguments(ListValue.newBuilder()
.addValues(Value.newBuilder().setNumberValue(i)))
.build());
}
requestStream.onCompleted();Arguments are a JSON array. Each element maps to a method parameter in order.
# String
"arguments": ["CUST001"]
# Number
"arguments": [42]
# Boolean
"arguments": [true]
# Multiple mixed arguments
"arguments": ["CUST001", 99.99, true]
# Null
"arguments": [null]Pass a Java object as a JSON object. NetScope deserializes it into the target class automatically — field names must match the Java class fields (or @JsonProperty aliases).
grpcurl -plaintext \
-H 'authorization: Bearer eyJhbGci...' \
-d '{
"bean_name": "OrderService",
"member_name": "createOrder",
"arguments": [
{"customerId": "CUST001", "amount": 99.99, "express": true}
]
}' localhost:9090 netscope.NetScopeService/InvokeMethodJava client:
Struct orderStruct = Struct.newBuilder()
.putFields("customerId", Value.newBuilder().setStringValue("CUST001").build())
.putFields("amount", Value.newBuilder().setNumberValue(99.99).build())
.putFields("express", Value.newBuilder().setBoolValue(true).build())
.build();
InvokeRequest request = InvokeRequest.newBuilder()
.setBeanName("OrderService")
.setMemberName("createOrder")
.setArguments(ListValue.newBuilder()
.addValues(Value.newBuilder().setStructValue(orderStruct)))
.build();Python client:
order = struct_pb2.Struct()
order.update({"customerId": "CUST001", "amount": 99.99, "express": True})
request = InvokeRequest(bean_name="OrderService", member_name="createOrder")
request.arguments.values.append(struct_pb2.Value(struct_value=order))Tips for complex types:
- Nested object — use a nested JSON object inside the parent:
{"address": {"street": "123 Main St", "city": "NY"}} - List of objects — use a JSON array:
[{"id": 1}, {"id": 2}]
NetScope automatically picks the right overload by matching argument types. Most of the time you don't need to do anything extra.
# NetScope infers: process(String) because the argument is a string
"member_name": "process",
"arguments": ["ORD001"]When automatic inference isn't enough — if two overloads are equally compatible (e.g. process(int) vs process(long), both numeric), add parameter_types:
grpcurl -plaintext \
-H 'authorization: Bearer eyJhbGci...' \
-d '{
"bean_name": "OrderService",
"member_name": "process",
"parameter_types": ["int"],
"arguments": [42]
}' localhost:9090 netscope.NetScopeService/InvokeMethodUse the exact type names shown by GetDocs (ParameterInfo.type) for parameter_types.
Credentials go in gRPC metadata headers, not in the request body.
| Header | Example value | Used for |
|---|---|---|
authorization |
Bearer eyJhbGci... |
OAuth 2.0 JWT |
x-api-key |
your-api-key |
API key |
# OAuth
grpcurl -plaintext -H 'authorization: Bearer eyJhbGci...' ...
# API key
grpcurl -plaintext -H 'x-api-key: your-api-key' ...NetScope accepts tokens signed with any of the following algorithms, covering all major OAuth 2.0 providers out of the box:
| Algorithm | Type |
|---|---|
| RS256, RS384, RS512 | RSA (Keycloak default, Azure AD, most providers) |
| ES256, ES384, ES512 | ECDSA (Auth0 optional, Okta optional) |
The api-key.keys setting accepts a list, allowing key rotation without downtime:
netscope:
server:
security:
api-key:
enabled: true
keys:
- current-key
- new-key # add new key, deploy, then remove old key in the next deployUse InvokeMethod the same way you'd call a method — just omit arguments:
grpcurl -plaintext \
-d '{"bean_name": "AppService", "member_name": "maintenanceMode"}' \
localhost:9090 netscope.NetScopeService/InvokeMethod{ "result": false }Use SetAttribute. The response includes the previous value.
grpcurl -plaintext \
-H 'x-api-key: your-api-key' \
-d '{
"bean_name": "AppService",
"attribute_name": "maintenanceMode",
"value": true
}' localhost:9090 netscope.NetScopeService/SetAttribute{ "previousValue": false }
finalfields are read-only.SetAttributeon a final field returnsFAILED_PRECONDITION.
GetDocs returns every exposed member with its full signature:
grpcurl -plaintext -d '{}' localhost:9090 netscope.NetScopeService/GetDocsEach entry includes:
| Field | Meaning |
|---|---|
bean_name |
Spring bean name to use in requests |
member_name |
Method or field name |
kind |
METHOD or FIELD |
return_type |
Java return type |
parameters |
Parameter names, types, and positions |
secured |
true if authentication is required |
writeable |
true for non-final fields |
is_static |
true for static members |
is_final |
true for final fields |
description |
Text from the annotation's description |
| Status | When |
|---|---|
OK |
Success |
NOT_FOUND |
Bean or member name not found in the registry |
UNAUTHENTICATED |
Missing or invalid credential |
PERMISSION_DENIED |
Wrong credential type (e.g. API key sent to an OAuth-only method) |
FAILED_PRECONDITION |
Attempt to write a final field |
INVALID_ARGUMENT |
Wrong number of arguments; SetAttribute called on a method; ambiguous overload that couldn't be resolved automatically |
INTERNAL |
Unexpected server error |
netscope:
server:
security:
oauth:
enabled: true
issuerUri: https://keycloak.example.com/realms/myrealm
jwkSetUri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs
audiences:
- accountnetscope:
server:
security:
oauth:
enabled: true
issuerUri: https://your-tenant.auth0.com/
jwkSetUri: https://your-tenant.auth0.com/.well-known/jwks.json
audiences:
- https://your-api.example.comnetscope:
server:
security:
oauth:
enabled: true
issuerUri: https://login.microsoftonline.com/{tenant-id}/v2.0
jwkSetUri: https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys
audiences:
- api://{client-id}- Bean name and member name are case-sensitive
- The method or field must have
@NetworkPublicor@NetworkSecured - The bean must be a Spring-managed component (
@Service,@Component, etc.) - You can use either the concrete class name (
CustomerServiceImpl) or the interface name (CustomerService) asbean_name— check the startup log for registered aliases
- Confirm the
authorizationorx-api-keyheader is present in the request metadata (not the body) - Check that the JWT is not expired and its issuer/audience match your
application.yml
SetAttributecannot write tofinalfields; useInvokeMethodto read them
- You used a method name —
SetAttributeis for fields only; useInvokeMethodfor methods
- Multiple overloads matched and automatic inference couldn't pick one (e.g.
process(int)vsprocess(long)— both accept a numeric argument) - Add
parameter_typeswith the exact type name fromGetDocs:"parameter_types": ["int"]
logging:
level:
org.fractalx.netscope.server: DEBUG
io.grpc: INFO- Always enable TLS in production
- Use short-lived JWT tokens (15–60 minutes) with token refresh on the client
- Prefer
AuthType.OAUTHfor user-facing endpoints andAuthType.API_KEYfor service-to-service calls - Never annotate fields that hold credentials or secrets with
@NetworkPublic - Mark attributes that must not be modified remotely as
final
Apache License 2.0
- Sathnindu Kottage — @sathninduk
- FractalX Team — https://github.com/project-FractalX