Skip to content

Commit 77f9f64

Browse files
authored
Merge pull request #3512 from OpenFeign/graphql-subscriptions
Add GraphQL subscription support over graphql-transport-ws
2 parents 27e0152 + 3577c00 commit 77f9f64

8 files changed

Lines changed: 1680 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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,89 @@ 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(...)`. Cancelling either one closes the underlying WebSocket.
167+
168+
Those two asynchronous forms each need a worker for as long as the subscription is open. They run on
169+
a bounded daemon pool by default; pass your own to own the lifecycle:
170+
171+
```java
172+
new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5), myExecutor)
173+
```
174+
175+
Reads are demand-driven — the client asks the socket for another frame only once the consumer has
176+
taken the previous event — so a slow consumer applies backpressure to the server instead of growing
177+
a queue in memory.
178+
179+
`Flow.Publisher` is `java.util.concurrent.Flow.Publisher`, so it plugs into Reactor
180+
(`JdkFlowAdapter.flowPublisherToFlux`) or RxJava (`Flowable.fromPublisher`) without extra
181+
dependencies here.
182+
183+
A server `error` message, or `errors` inside a payload, is raised as `GraphqlErrorException`.
184+
Request headers (for example `Authorization`) are forwarded to the WebSocket handshake.
185+
103186
## Custom Scalars
104187

105188
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: 66 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,7 +25,11 @@
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;
30+
import java.util.concurrent.Executor;
31+
import java.util.concurrent.Executors;
32+
import java.util.concurrent.atomic.AtomicLong;
2833

2934
@Experimental
3035
public class GraphqlCapability implements Capability {
@@ -33,15 +38,70 @@ public class GraphqlCapability implements Capability {
3338
private final GraphqlEncoder graphqlEncoder;
3439
private final GraphqlDecoder graphqlDecoder;
3540
private final GraphqlRequestInterceptor interceptor;
41+
private final JsonEncoder jsonEncoder;
42+
private final JsonDecoder jsonDecoder;
3643

3744
public GraphqlCapability(JsonCodec codec) {
3845
this(codec.encoder(), codec.decoder());
3946
}
4047

48+
/**
49+
* @param eventTimeout how long a blocking subscription call waits for an event before failing
50+
* with {@link java.net.SocketTimeoutException}; {@link java.time.Duration#ZERO} waits
51+
* indefinitely. Does not apply to {@code Flow.Publisher} or {@code CompletableFuture}
52+
* subscriptions, whose caller owns the deadline.
53+
*/
54+
public GraphqlCapability(JsonCodec codec, Duration eventTimeout) {
55+
this(codec.encoder(), codec.decoder(), eventTimeout);
56+
}
57+
58+
/**
59+
* @param executor runs the worker behind each {@code Flow.Publisher} and {@code
60+
* CompletableFuture} subscription, and delivers to their subscribers. Supply your own to own
61+
* the lifecycle; the default is bounded and daemon, and is never shut down.
62+
*/
63+
public GraphqlCapability(JsonCodec codec, Duration eventTimeout, Executor executor) {
64+
this(codec.encoder(), codec.decoder(), eventTimeout, executor);
65+
}
66+
4167
public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) {
68+
this(encoder, decoder, GraphqlDecoder.DEFAULT_EVENT_TIMEOUT);
69+
}
70+
71+
/**
72+
* @param eventTimeout see {@link #GraphqlCapability(JsonCodec, Duration)}
73+
*/
74+
public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout) {
75+
this(encoder, decoder, eventTimeout, defaultExecutor());
76+
}
77+
78+
/**
79+
* @param executor see {@link #GraphqlCapability(JsonCodec, Duration, Executor)}
80+
*/
81+
public GraphqlCapability(
82+
JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout, Executor executor) {
4283
this.graphqlEncoder = new GraphqlEncoder(encoder, contract);
43-
this.graphqlDecoder = new GraphqlDecoder(decoder);
84+
this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout, executor);
4485
this.interceptor = new GraphqlRequestInterceptor(encoder, contract);
86+
this.jsonEncoder = encoder;
87+
this.jsonDecoder = decoder;
88+
}
89+
90+
/**
91+
* Each open {@code Flow.Publisher} or {@code CompletableFuture} subscription holds one worker for
92+
* its lifetime, so the default pool grows on demand and reaps idle threads rather than capping
93+
* concurrent subscriptions at a guess. Supply a bounded executor to cap them deliberately: the
94+
* excess is refused with {@code RejectedExecutionException} rather than left hanging.
95+
*/
96+
private static Executor defaultExecutor() {
97+
var threads = new AtomicLong();
98+
return Executors.newCachedThreadPool(
99+
runnable -> {
100+
var thread =
101+
new Thread(runnable, "feign-graphql-subscription-" + threads.incrementAndGet());
102+
thread.setDaemon(true);
103+
return thread;
104+
});
45105
}
46106

47107
@Override
@@ -59,6 +119,11 @@ public Decoder enrich(Decoder decoder) {
59119
return graphqlDecoder;
60120
}
61121

122+
@Override
123+
public Client enrich(Client client) {
124+
return new GraphqlSubscriptionClient(client, contract, jsonEncoder, jsonDecoder);
125+
}
126+
62127
@Override
63128
public RequestInterceptors enrich(RequestInterceptors requestInterceptors) {
64129
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)