Skip to content

Commit 9d52a52

Browse files
committed
Add GraphQL subscription support over graphql-transport-ws
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
1 parent 4226185 commit 9d52a52

7 files changed

Lines changed: 1038 additions & 15 deletions

File tree

graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,12 +358,15 @@ private boolean isJavaBuiltIn(String typeName) {
358358
return JAVA_BUILT_INS.contains(typeName);
359359
}
360360

361+
/** Wrappers that carry the operation result rather than being it. */
362+
private static final Set<String> RESULT_CONTAINERS = Set.of("List", "Stream", "Publisher");
363+
361364
private String getSimpleTypeName(TypeMirror typeMirror) {
362365
if (typeMirror instanceof DeclaredType declaredType) {
363366
var typeElement = declaredType.asElement();
364367
var simpleName = typeElement.getSimpleName().toString();
365368

366-
if ("List".equals(simpleName)) {
369+
if (RESULT_CONTAINERS.contains(simpleName)) {
367370
var typeArgs = declaredType.getTypeArguments();
368371
if (!typeArgs.isEmpty()) {
369372
return getSimpleTypeName(typeArgs.get(0));
@@ -376,7 +379,7 @@ private String getSimpleTypeName(TypeMirror typeMirror) {
376379
}
377380

378381
private boolean isExistingExternalType(TypeMirror typeMirror, String targetPackage) {
379-
var unwrapped = unwrapListTypeMirror(typeMirror);
382+
var unwrapped = unwrapContainerTypeMirror(typeMirror);
380383
if (unwrapped.getKind() == TypeKind.ERROR) {
381384
return false;
382385
}
@@ -392,13 +395,13 @@ private boolean isExistingExternalType(TypeMirror typeMirror, String targetPacka
392395
return false;
393396
}
394397

395-
private TypeMirror unwrapListTypeMirror(TypeMirror typeMirror) {
398+
private TypeMirror unwrapContainerTypeMirror(TypeMirror typeMirror) {
396399
if (typeMirror instanceof DeclaredType declaredType) {
397400
var simpleName = declaredType.asElement().getSimpleName().toString();
398-
if ("List".equals(simpleName)) {
401+
if (RESULT_CONTAINERS.contains(simpleName)) {
399402
var typeArgs = declaredType.getTypeArguments();
400403
if (!typeArgs.isEmpty()) {
401-
return typeArgs.get(0);
404+
return unwrapContainerTypeMirror(typeArgs.get(0));
402405
}
403406
}
404407
}

graphql/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,78 @@ The processor generates a record for the input type as well:
100100
public record CreateUserInput(String name, String email) {}
101101
```
102102

103+
## Subscriptions
104+
105+
`subscription` operations are detected from the query text and executed over the
106+
[graphql-transport-ws](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) WebSocket
107+
protocol instead of HTTP. The endpoint is the target URL with its scheme swapped to `ws`/`wss`, and
108+
one connection is opened per call.
109+
110+
Queries, mutations and subscriptions can live on the same interface: only subscriptions are routed
111+
to a WebSocket, everything else goes over the regular Feign client — including whichever one you
112+
configured with `.client(...)` — with its own timeouts, retryer and interceptors unchanged.
113+
114+
The return type decides how many events you get and whether the call blocks:
115+
116+
| Return type | Events | Behaviour |
117+
| --- | --- | --- |
118+
| `T` | first only | blocks until the first event, then unsubscribes |
119+
| `Optional<T>` | first only | as above, empty if the server completes without one |
120+
| `CompletableFuture<T>` | first only | returns immediately, completes with the first event |
121+
| `Stream<T>` | all | returns once subscribed, then blocks on each element |
122+
| `Flow.Publisher<T>` | all | returns immediately, elements are pushed to the subscriber |
123+
124+
```java
125+
@GraphqlSchema("my-schema.graphql")
126+
interface StockApi {
127+
128+
@GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
129+
Price nextPrice(@Param("symbol") String symbol);
130+
131+
@GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
132+
Stream<Price> onPrice(@Param("symbol") String symbol);
133+
134+
@GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }")
135+
Flow.Publisher<Price> publishPrice(@Param("symbol") String symbol);
136+
}
137+
```
138+
139+
The single-event forms close the subscription as soon as they have their event. The multi-event
140+
forms hand you the lifecycle: closing the `Stream` — or cancelling the `Flow.Subscription` — sends
141+
`complete` and closes the WebSocket, so consume a `Stream` with try-with-resources:
142+
143+
```java
144+
try (var prices = api.onPrice("ACME")) {
145+
prices.forEach(System.out::println);
146+
}
147+
```
148+
149+
`Stream` here is the ordinary `java.util.stream.Stream`: synchronous and pull-based, with no timeout
150+
facilities of its own. So the blocking forms — `T`, `Optional<T>` and `Stream<T>` — are bounded by
151+
an event timeout, which defaults to **60 seconds** and applies to each event rather than to the
152+
subscription as a whole. Override it when creating the capability:
153+
154+
```java
155+
Feign.builder()
156+
// wait at most 5s for each event; Duration.ZERO waits indefinitely
157+
.addCapability(new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5)))
158+
.target(StockApi.class, "https://example.com/graphql");
159+
```
160+
161+
Exceeding it raises `SocketTimeoutException` from the blocking call or the stream element. A
162+
subscription that can legitimately sit idle for longer needs `Duration.ZERO`.
163+
164+
`Flow.Publisher<T>` and `CompletableFuture<T>` are deliberately *not* bounded by it — their caller
165+
already owns the deadline, via cancelling the subscription or
166+
`get(timeout, unit)`/`orTimeout(...)`.
167+
168+
`Flow.Publisher` is `java.util.concurrent.Flow.Publisher`, so it plugs into Reactor
169+
(`JdkFlowAdapter.flowPublisherToFlux`) or RxJava (`Flowable.fromPublisher`) without extra
170+
dependencies here.
171+
172+
A server `error` message, or `errors` inside a payload, is raised as `GraphqlErrorException`.
173+
Request headers (for example `Authorization`) are forwarded to the WebSocket handshake.
174+
103175
## Custom Scalars
104176

105177
When your schema defines custom scalars, map them to Java types using `@Scalar` on default methods:

graphql/src/main/java/feign/graphql/GraphqlCapability.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package feign.graphql;
1717

1818
import feign.Capability;
19+
import feign.Client;
1920
import feign.Contract;
2021
import feign.Experimental;
2122
import feign.RequestInterceptors;
@@ -24,6 +25,7 @@
2425
import feign.codec.JsonCodec;
2526
import feign.codec.JsonDecoder;
2627
import feign.codec.JsonEncoder;
28+
import java.time.Duration;
2729
import java.util.ArrayList;
2830

2931
@Experimental
@@ -33,15 +35,34 @@ public class GraphqlCapability implements Capability {
3335
private final GraphqlEncoder graphqlEncoder;
3436
private final GraphqlDecoder graphqlDecoder;
3537
private final GraphqlRequestInterceptor interceptor;
38+
private final JsonDecoder jsonDecoder;
3639

3740
public GraphqlCapability(JsonCodec codec) {
3841
this(codec.encoder(), codec.decoder());
3942
}
4043

44+
/**
45+
* @param eventTimeout how long a blocking subscription call waits for an event before failing
46+
* with {@link java.net.SocketTimeoutException}; {@link java.time.Duration#ZERO} waits
47+
* indefinitely. Does not apply to {@code Flow.Publisher} or {@code CompletableFuture}
48+
* subscriptions, whose caller owns the deadline.
49+
*/
50+
public GraphqlCapability(JsonCodec codec, Duration eventTimeout) {
51+
this(codec.encoder(), codec.decoder(), eventTimeout);
52+
}
53+
4154
public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) {
55+
this(encoder, decoder, GraphqlDecoder.DEFAULT_EVENT_TIMEOUT);
56+
}
57+
58+
/**
59+
* @param eventTimeout see {@link #GraphqlCapability(JsonCodec, Duration)}
60+
*/
61+
public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout) {
4262
this.graphqlEncoder = new GraphqlEncoder(encoder, contract);
43-
this.graphqlDecoder = new GraphqlDecoder(decoder);
63+
this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout);
4464
this.interceptor = new GraphqlRequestInterceptor(encoder, contract);
65+
this.jsonDecoder = decoder;
4566
}
4667

4768
@Override
@@ -59,6 +80,11 @@ public Decoder enrich(Decoder decoder) {
5980
return graphqlDecoder;
6081
}
6182

83+
@Override
84+
public Client enrich(Client client) {
85+
return new GraphqlSubscriptionClient(client, contract, jsonDecoder);
86+
}
87+
6288
@Override
6389
public RequestInterceptors enrich(RequestInterceptors requestInterceptors) {
6490
var enriched = new ArrayList<>(requestInterceptors.interceptors());

graphql/src/main/java/feign/graphql/GraphqlContract.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public class GraphqlContract extends DefaultContract {
3131

3232
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\s*(\\w+)\\s*:");
3333

34+
private static final Pattern SUBSCRIPTION_PATTERN = Pattern.compile("^\\s*subscription\\b");
35+
3436
private final Map<String, QueryMetadata> metadata = new ConcurrentHashMap<>();
3537

3638
public GraphqlContract() {
@@ -45,7 +47,8 @@ public GraphqlContract() {
4547
}
4648

4749
var variableName = extractFirstVariable(query);
48-
metadata.put(data.configKey(), new QueryMetadata(query, variableName));
50+
metadata.put(
51+
data.configKey(), new QueryMetadata(query, variableName, isSubscription(query)));
4952
});
5053
}
5154

@@ -97,13 +100,19 @@ static String extractFirstVariable(String query) {
97100
return null;
98101
}
99102

103+
static boolean isSubscription(String query) {
104+
return SUBSCRIPTION_PATTERN.matcher(query).find();
105+
}
106+
100107
static class QueryMetadata {
101108
final String query;
102109
final String variableName;
110+
final boolean subscription;
103111

104-
QueryMetadata(String query, String variableName) {
112+
QueryMetadata(String query, String variableName, boolean subscription) {
105113
this.query = query;
106114
this.variableName = variableName;
115+
this.subscription = subscription;
107116
}
108117
}
109118
}

0 commit comments

Comments
 (0)