diff --git a/nakama/lib/nakama.dart b/nakama/lib/nakama.dart index 124ad26..b79e69d 100644 --- a/nakama/lib/nakama.dart +++ b/nakama/lib/nakama.dart @@ -26,7 +26,6 @@ export 'src/models/matchmaker.dart' PartyMatchmakerTicket; export 'src/models/notification.dart' show Notification, NotificationList; export 'src/models/party.dart' show PartyData, PartyLeader, PartyPresenceEvent; -export 'src/models/rpc.dart' show Rpc; export 'src/models/session.dart' show Session; export 'src/models/status.dart' show diff --git a/nakama/lib/src/client.dart b/nakama/lib/src/client.dart index f03c840..4c5fbb5 100644 --- a/nakama/lib/src/client.dart +++ b/nakama/lib/src/client.dart @@ -929,9 +929,12 @@ abstract interface class Client { /// /// - [id] The ID of the function to execute. /// - [payload] The payload to send with the function call. - Future rpc({ + /// - [httpKey] The HTTP key to use for the function call. Not supported by + /// gRPC protocol. + Future?> rpc({ required String id, - String? payload, + Map? payload, + String? httpKey, }); } @@ -949,6 +952,7 @@ abstract base class ClientBase implements Client { }); static bool get _withoutSession => Zone.current[#_withoutSession]! as bool; + static String get _httpKey => Zone.current[#_httpKey]! as String; @override final String host; @@ -971,13 +975,14 @@ abstract base class ClientBase implements Client { String get authorizationHeader { return switch (session) { final session? when !_withoutSession => 'Bearer ${session.token}', - _ => 'Basic ${base64Encode('$serverKey:'.codeUnits)}' + _ => 'Basic ${base64Encode('$_httpKey:'.codeUnits)}' }; } Future _performRequest( Future Function() request, { bool withoutSession = false, + String? httpKey, }) async { if (session case Session(isExpired: true, isRefreshExpired: false, :final vars) @@ -997,7 +1002,10 @@ abstract base class ClientBase implements Client { try { return await runZoned( request, - zoneValues: {#_withoutSession: withoutSession}, + zoneValues: { + #_withoutSession: withoutSession, + #_httpKey: httpKey ?? serverKey, + }, ); } on Exception catch (exception) { if (translateException(exception) case final translatedException?) { @@ -1517,9 +1525,9 @@ abstract base class ClientBase implements Client { }); @visibleForOverriding - Future performRpc({ + Future?> performRpc({ required String id, - String? payload, + Map? payload, }); @override @@ -1539,7 +1547,7 @@ abstract base class ClientBase implements Client { ); } - session = await _performRequest(withoutSession: true,() { + session = await _performRequest(withoutSession: true, () { return performSessionRefresh(vars: vars); }); @@ -2580,11 +2588,12 @@ abstract base class ClientBase implements Client { } @override - Future rpc({ + Future?> rpc({ required String id, - String? payload, + Map? payload, + String? httpKey, }) { - return _performRequest(() { + return _performRequest(httpKey: httpKey, () { return performRpc( id: id, payload: payload, diff --git a/nakama/lib/src/grpc_client.dart b/nakama/lib/src/grpc_client.dart index c399cbb..8583c12 100644 --- a/nakama/lib/src/grpc_client.dart +++ b/nakama/lib/src/grpc_client.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:grpc/grpc.dart' hide Client; @@ -1207,18 +1208,33 @@ final class GrpcClient extends ClientBase { } @override - Future performRpc({ + Future?> performRpc({ required String id, - String? payload, + Map? payload, }) async { final res = await _client.rpcFunc( api.Rpc( id: id, - payload: payload, + payload: payload != null ? jsonEncode(payload) : null, ), ); - return res.payload; + return res.payload.isEmpty ? null : jsonDecode(res.payload); + } + + @override + Future?> rpc({ + required String id, + Map? payload, + String? httpKey, + }) async { + if (httpKey != null) { + throw NakamaError( + code: ErrorCode.invalidArgument, + message: 'RPC with HTTP key is not supported by gRPC protocol.', + ); + } + return super.rpc(id: id, payload: payload, httpKey: httpKey); } @override diff --git a/nakama/lib/src/models/rpc.dart b/nakama/lib/src/models/rpc.dart deleted file mode 100644 index 9114f64..0000000 --- a/nakama/lib/src/models/rpc.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../api/api.dart' as api; - -part 'rpc.freezed.dart'; - -@freezed -class Rpc with _$Rpc { - const Rpc._(); - - const factory Rpc({ - required String payload, - }) = _Rpc; - - factory Rpc.fromDto(api.Rpc dto) => Rpc(payload: dto.payload); -} diff --git a/nakama/lib/src/models/rpc.freezed.dart b/nakama/lib/src/models/rpc.freezed.dart deleted file mode 100644 index e5ab660..0000000 --- a/nakama/lib/src/models/rpc.freezed.dart +++ /dev/null @@ -1,126 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'rpc.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -/// @nodoc -mixin _$Rpc { - String get payload => throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $RpcCopyWith get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $RpcCopyWith<$Res> { - factory $RpcCopyWith(Rpc value, $Res Function(Rpc) then) = - _$RpcCopyWithImpl<$Res, Rpc>; - @useResult - $Res call({String payload}); -} - -/// @nodoc -class _$RpcCopyWithImpl<$Res, $Val extends Rpc> implements $RpcCopyWith<$Res> { - _$RpcCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? payload = null, - }) { - return _then(_value.copyWith( - payload: null == payload - ? _value.payload - : payload // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$RpcImplCopyWith<$Res> implements $RpcCopyWith<$Res> { - factory _$$RpcImplCopyWith(_$RpcImpl value, $Res Function(_$RpcImpl) then) = - __$$RpcImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String payload}); -} - -/// @nodoc -class __$$RpcImplCopyWithImpl<$Res> extends _$RpcCopyWithImpl<$Res, _$RpcImpl> - implements _$$RpcImplCopyWith<$Res> { - __$$RpcImplCopyWithImpl(_$RpcImpl _value, $Res Function(_$RpcImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? payload = null, - }) { - return _then(_$RpcImpl( - payload: null == payload - ? _value.payload - : payload // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc - -class _$RpcImpl extends _Rpc { - const _$RpcImpl({required this.payload}) : super._(); - - @override - final String payload; - - @override - String toString() { - return 'Rpc(payload: $payload)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$RpcImpl && - (identical(other.payload, payload) || other.payload == payload)); - } - - @override - int get hashCode => Object.hash(runtimeType, payload); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$RpcImplCopyWith<_$RpcImpl> get copyWith => - __$$RpcImplCopyWithImpl<_$RpcImpl>(this, _$identity); -} - -abstract class _Rpc extends Rpc { - const factory _Rpc({required final String payload}) = _$RpcImpl; - const _Rpc._() : super._(); - - @override - String get payload; - @override - @JsonKey(ignore: true) - _$$RpcImplCopyWith<_$RpcImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/nakama/lib/src/rest_client.dart b/nakama/lib/src/rest_client.dart index 449eea9..2ad13e6 100644 --- a/nakama/lib/src/rest_client.dart +++ b/nakama/lib/src/rest_client.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; @@ -1137,15 +1138,20 @@ final class RestClient extends ClientBase { } @override - Future performRpc({ + Future?> performRpc({ required String id, - String? payload, + Map? payload, }) async { final result = await switch (payload) { - final payload? => _api.rpcFunc(id: id, body: payload), - _ => _api.rpcFunc2(id: id) + final payload? => _api.rpcFunc( + id: id, + // The payload is double-encoded because the Nakama server expects + // it this way. + body: jsonEncode(jsonEncode(payload)), + ), + _ => _api.rpcFunc2(id: id), }; - return result.payload; + return result.payload?.isEmpty ?? true ? null : jsonDecode(result.payload!); } } diff --git a/nakama/lib/src/socket.dart b/nakama/lib/src/socket.dart index 5051eb2..5bffd27 100644 --- a/nakama/lib/src/socket.dart +++ b/nakama/lib/src/socket.dart @@ -13,7 +13,6 @@ import 'models/match.dart'; import 'models/matchmaker.dart'; import 'models/notification.dart'; import 'models/party.dart'; -import 'models/rpc.dart'; import 'models/status.dart'; /// A socket for real-time communication with the Nakama server. @@ -83,7 +82,10 @@ abstract class Socket { Future removeMatchmaker(String ticket); - Future rpc({required String id, String? payload}); + Future?> rpc({ + required String id, + Map? payload, + }); Future> followUsers({ List? userIds, @@ -287,7 +289,7 @@ class SocketImpl implements Socket { PartyMatchmakerTicket.fromDto(envelope.partyMatchmakerTicket), ); case rtapi.Envelope_Message.rpc: - _completePendingRequest(envelope.cid, Rpc.fromDto(envelope.rpc)); + _completePendingRequest(envelope.cid, envelope.rpc); case rtapi.Envelope_Message.party: _completePendingRequest(envelope.cid, Party.fromDto(envelope.party)); case rtapi.Envelope_Message.channelPresenceEvent: @@ -595,12 +597,21 @@ class SocketImpl implements Socket { } @override - Future rpc({required String id, String? payload}) { - return _send( + Future?> rpc({ + required String id, + Map? payload, + }) async { + final result = await _send( rtapi.Envelope( - rpc: api.Rpc(id: id, payload: payload), + rpc: api.Rpc( + id: id, + payload: payload != null ? jsonEncode(payload) : null, + ), ), ); + return result.payload.isEmpty + ? null + : jsonDecode(result.payload) as Map; } @override diff --git a/nakama/test/client/client_test.dart b/nakama/test/client/client_test.dart index 1705fb6..408a775 100644 --- a/nakama/test/client/client_test.dart +++ b/nakama/test/client/client_test.dart @@ -35,5 +35,46 @@ void main() { await client.getAccount(); expect(client.session, isNot(expiredSession)); }); + + group('rpc', () { + clientTest('without input and output', () async { + final client = helper.createClient(); + await client.authenticateCustom(id: faker.guid.guid()); + expectLater(client.echo(), completion(isNull)); + }); + + clientTest('with input and output', () async { + final client = helper.createClient(); + await client.authenticateCustom(id: faker.guid.guid()); + expectLater(client.echo(input: {}), completion({})); + }); + + clientTest('with http key', () async { + final client = helper.createClient(); + await expectLater( + client.echo(httpKey: 'defaulthttpkey'), + switch (helper.clientType) { + ClientType.rest => completion(isNull), + // The gRPC client does not support HTTP keys. + ClientType.grpc => + throwsA(isA().havingCode(ErrorCode.invalidArgument)), + }, + ); + }); + + clientTest('with incorrect http key', () async { + final client = helper.createClient(); + await expectLater( + client.echo(httpKey: 'invalid'), + switch (helper.clientType) { + ClientType.rest => + throwsA(isA().havingCode(ErrorCode.unauthenticated)), + // The gRPC client does not support HTTP keys. + ClientType.grpc => + throwsA(isA().havingCode(ErrorCode.invalidArgument)), + }, + ); + }); + }); }); } diff --git a/nakama/test/client/leaderboard_test.dart b/nakama/test/client/leaderboard_test.dart index f5a87f1..5559a39 100644 --- a/nakama/test/client/leaderboard_test.dart +++ b/nakama/test/client/leaderboard_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:faker/faker.dart'; import 'package:nakama/nakama.dart'; import 'package:test/test.dart'; @@ -8,33 +6,26 @@ import '../helpers.dart'; void main() { clientTests((helper) { - group('Leaderboard', skip: 'TODO: add missing RPC function', () { + group('Leaderboard', () { late final Client client; - late final String leaderboardName; + late final String leaderboardId; setUpAll(() async { client = helper.createClient(); await client.authenticateDevice(deviceId: faker.guid.guid()); - // Create leaderboard - final result = await client.rpc( - id: 'clientrpc.create_leaderboard', - payload: jsonEncode({'operator': 'best'}), - ); - - final payload = jsonDecode(result!); - leaderboardName = payload['leaderboard_id']; + leaderboardId = await client.createLeaderboard(); }); clientTest('list records', () async { await client.writeLeaderboardRecord( - leaderboardId: leaderboardName, + leaderboardId: leaderboardId, score: 10, ); final result = await client.listLeaderboardRecords( - leaderboardId: leaderboardName, + leaderboardId: leaderboardId, ); expect(result, isA()); @@ -44,7 +35,7 @@ void main() { clientTest('write record', () async { final result = await client.writeLeaderboardRecord( - leaderboardId: leaderboardName, + leaderboardId: leaderboardId, score: 10, ); @@ -55,12 +46,12 @@ void main() { clientTest('list records around user', () async { await client.writeLeaderboardRecord( - leaderboardId: leaderboardName, + leaderboardId: leaderboardId, score: 10, ); final result = await client.listLeaderboardRecordsAroundOwner( - leaderboardId: leaderboardName, + leaderboardId: leaderboardId, ownerId: client.session!.userId, ); diff --git a/nakama/test/helpers.dart b/nakama/test/helpers.dart index 48dbb8c..0a15f95 100644 --- a/nakama/test/helpers.dart +++ b/nakama/test/helpers.dart @@ -69,9 +69,32 @@ class TestHelper { Future close() async => await (await _client).close(); } -extension on Client { +enum LeaderboardOperator { + best, + set, + increment, +} + +extension RpcTestingExtensions on Client { + Future?> echo({ + Map? input, + String? httpKey, + }) => + rpc(id: 'testing.echo', payload: input, httpKey: httpKey); + Future deleteAllGroups() async => await rpc(id: 'testing.delete_all_groups'); + + Future createLeaderboard({ + LeaderboardOperator operator = LeaderboardOperator.best, + }) async { + final result = await rpc( + id: 'testing.create_leaderboard', + payload: {'operator': operator.name}, + ); + + return result!['leaderboard_id']! as String; + } } void withTestHelper( diff --git a/nakama/test/socket/socket_test.dart b/nakama/test/socket/socket_test.dart new file mode 100644 index 0000000..cddf4c4 --- /dev/null +++ b/nakama/test/socket/socket_test.dart @@ -0,0 +1,30 @@ +import 'package:faker/faker.dart'; +import 'package:test/test.dart'; + +import '../helpers.dart'; + +void main() { + withTestHelper((helper) { + group('rpc', () { + test('with input and output', () async { + final client = helper.createClient(); + await client.authenticateCustom(id: faker.guid.guid()); + final socket = await helper.createSocket(client); + expectLater( + socket.rpc(id: 'testing.echo', payload: {}), + completion({}), + ); + }); + + test('without input and output', () async { + final client = helper.createClient(); + await client.authenticateCustom(id: faker.guid.guid()); + final socket = await helper.createSocket(client); + expectLater( + socket.rpc(id: 'testing.echo'), + completion(isNull), + ); + }); + }); + }); +} diff --git a/nakama/test/test_server/src/main.ts b/nakama/test/test_server/src/main.ts index 1ba73af..5697553 100644 --- a/nakama/test/test_server/src/main.ts +++ b/nakama/test/test_server/src/main.ts @@ -4,10 +4,27 @@ let InitModule: nkruntime.InitModule = function ( nk: nkruntime.Nakama, initializer: nkruntime.Initializer ) { - initializer.registerRpc('testing.delete_all_groups', deleteAllGroups) + initializer.registerRpc('testing.echo', rpcTestingEcho) + initializer.registerRpc( + 'testing.delete_all_groups', + rpcTestingDeleteAllGroups + ) + initializer.registerRpc( + 'testing.create_leaderboard', + rpcTestingCreateLeaderboard + ) } -function deleteAllGroups( +function rpcTestingEcho( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +) { + return payload +} + +function rpcTestingDeleteAllGroups( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, @@ -29,3 +46,15 @@ function listAllGroups(nk: nkruntime.Nakama): nkruntime.Group[] { return groups } + +function rpcTestingCreateLeaderboard( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +) { + const args = JSON.parse(payload) + const leaderboardId = nk.uuidv4() + nk.leaderboardCreate(leaderboardId, false, undefined, args.operator) + return JSON.stringify({ leaderboard_id: leaderboardId }) +} diff --git a/nakama/tool/coverage.sh b/nakama/tool/coverage.sh index 0ded9a4..3ca44f2 100755 --- a/nakama/tool/coverage.sh +++ b/nakama/tool/coverage.sh @@ -1,4 +1,5 @@ #!/bin/bash dart run coverage:test_with_coverage +lcov -r coverage/lcov.info "*/src/api/*" "*/*.g.dart" -o coverage/lcov.info genhtml coverage/lcov.info -o coverage/html