Skip to content

Commit 8b425cd

Browse files
authored
Merge pull request #184 from goldenm-software/development
feat: add GqlSubscription and WebSocket subscribe support (v3.8.4)
2 parents 481550f + 40aa615 commit 8b425cd

5 files changed

Lines changed: 105 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 3.8.4
4+
5+
- Added `GqlSubscription` class for composable GraphQL subscription query construction.
6+
- Added `LayrzConnector.subscribe()` method to open WebSocket subscriptions using the `graphql-transport-ws` protocol.
7+
38
## 3.8.3
49

510
- Added `attributes` field to `TableItem` and `TableItemInput` models to support asset attributes in workspace tables.

lib/src/api/api.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
library;
22

3+
import 'dart:async';
34
import 'dart:convert';
45

56
import 'package:dio/dio.dart';
67
import 'package:freezed_annotation/freezed_annotation.dart';
8+
import 'package:web_socket_channel/web_socket_channel.dart';
79

810
part 'api.freezed.dart';
911
part 'api.g.dart';

lib/src/api/src/api_connector.dart

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,92 @@ class LayrzConnector {
5252
'operationName': null,
5353
});
5454
}
55+
56+
/// [subscribe] opens a WebSocket connection and executes a [GqlSubscription] using the
57+
/// `graphql-transport-ws` protocol. Each server `next` message is emitted on the returned stream
58+
/// as a decoded `Map<String, dynamic>`. The stream closes when the server sends `complete` or
59+
/// the caller cancels the subscription.
60+
Stream<Map<String, dynamic>> subscribe(GqlSubscription gql) {
61+
final wsUri = uri.replace(scheme: uri.scheme == 'https' ? 'wss' : 'ws');
62+
63+
final controller = StreamController<Map<String, dynamic>>();
64+
WebSocketChannel? channel;
65+
StreamSubscription<dynamic>? sub;
66+
67+
// Use a unique subscription id per call.
68+
final id = DateTime.now().microsecondsSinceEpoch.toString();
69+
70+
final wsHeaders = Map<String, dynamic>.from(headers)..remove('Content-Type');
71+
72+
Future<void> connect() async {
73+
channel = WebSocketChannel.connect(
74+
wsUri,
75+
protocols: ['graphql-transport-ws'],
76+
);
77+
await channel!.ready;
78+
79+
// Send connection_init with auth headers as payload.
80+
channel!.sink.add(jsonEncode({'type': 'connection_init', 'payload': wsHeaders}));
81+
82+
final variables = <String, dynamic>{
83+
for (final v in gql.variables)
84+
if (v.value != null) v.name: v.value,
85+
};
86+
87+
sub = channel!.stream.listen(
88+
(raw) {
89+
final msg = jsonDecode(raw as String) as Map<String, dynamic>;
90+
final type = msg['type'] as String?;
91+
92+
switch (type) {
93+
case 'connection_ack':
94+
// Send the subscribe message once acknowledged.
95+
channel!.sink.add(jsonEncode({
96+
'id': id,
97+
'type': 'subscribe',
98+
'payload': {
99+
'query': gql.generated,
100+
'variables': variables,
101+
'operationName': null,
102+
},
103+
}));
104+
case 'next':
105+
if (msg['id'] == id) {
106+
final data = msg['payload']?['data'];
107+
if (data is Map<String, dynamic> && !controller.isClosed) {
108+
controller.add(data);
109+
}
110+
}
111+
case 'error':
112+
if (msg['id'] == id && !controller.isClosed) {
113+
controller.addError(msg['payload'] ?? 'Subscription error');
114+
}
115+
case 'complete':
116+
if (msg['id'] == id) {
117+
controller.close();
118+
}
119+
}
120+
},
121+
onError: (e) {
122+
if (!controller.isClosed) controller.addError(e);
123+
},
124+
onDone: () {
125+
if (!controller.isClosed) controller.close();
126+
},
127+
);
128+
}
129+
130+
connect();
131+
132+
controller.onCancel = () {
133+
// Send complete to server before closing.
134+
try {
135+
channel?.sink.add(jsonEncode({'id': id, 'type': 'complete'}));
136+
} catch (_) {}
137+
sub?.cancel();
138+
channel?.sink.close();
139+
};
140+
141+
return controller.stream;
142+
}
55143
}

lib/src/api/src/gql_builder/gql.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ abstract class Gql {
4141
buffer.write('}\n\n');
4242
}
4343

44-
buffer.write(this is GqlMutation ? 'mutation' : 'query');
44+
buffer.write(this is GqlMutation ? 'mutation' : this is GqlSubscription ? 'subscription' : 'query');
4545

4646
if (name != null) {
4747
buffer.write(' $name');
@@ -50,8 +50,9 @@ abstract class Gql {
5050
if (variables.isNotEmpty) {
5151
buffer.write('(');
5252
buffer.write(variables.map((v) => '\$${v.name}: ${_renderType(v)}').join(', '));
53-
buffer.write(') {\n');
53+
buffer.write(')');
5454
}
55+
buffer.write(' {\n');
5556

5657
for (final field in fields) {
5758
buffer.write('${_writeField(field)}\n');
@@ -159,3 +160,8 @@ class GqlQuery extends Gql {
159160
class GqlMutation extends Gql {
160161
GqlMutation({super.name, required super.variables, super.fields, super.includeTypename});
161162
}
163+
164+
/// [GqlSubscription] represents a GraphQL subscription operation, used with [LayrzConnector.subscribe].
165+
class GqlSubscription extends Gql {
166+
GqlSubscription({super.name, required super.variables, super.fields, super.includeTypename});
167+
}

pubspec.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
description: Layrz API models for Dart/Flutter. This package contains the models
22
used by the Layrz API.
33
name: layrz_models
4-
version: "3.8.3"
4+
version: "3.8.4"
55
repository: https://github.com/goldenm-software/layrz_models
66

77
environment:
@@ -13,6 +13,7 @@ dependencies:
1313
sdk: flutter
1414

1515
dio: ^5.9.0
16+
web_socket_channel: ^3.0.1
1617
web: ^1.1.0
1718
collection: ^1.18.0
1819
recase: ^4.1.0

0 commit comments

Comments
 (0)