Repository: nyxx-discord/nyxx (dart)

Issue:
[rewrite] Implement HTTP Application Role Connection Metadata API
Ref: https://discord.com/developers/docs/resources/application-role-connection-metadata

Pull request:
# Description

Rewrite the application role connection metadata api

## Connected issues & other potential problems

Closes #454 

## Type of change

- [x] New feature (non-breaking change which adds functionality)

# Checklist:

- [x] Ran `dart analyze` or `make analyze` and fixed all issues
- [x] Ran `dart format --set-exit-if-changed -l 160 ./lib` or `make format` and fixed all issues
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works
- [ ] I have checked my changes haven't lowered code coverage

Patch:
diff --git a/lib/nyxx.dart b/lib/nyxx.dart
index 963617cb1..64ac9cc42 100644
--- a/lib/nyxx.dart
+++ b/lib/nyxx.dart
@@ -128,6 +128,8 @@ export 'src/models/guild/member.dart' show Member, MemberFlags, PartialMember;
 export 'src/models/guild/onboarding.dart' show Onboarding, OnboardingPrompt, OnboardingPromptOption, OnboardingPromptType;
 export 'src/models/guild/welcome_screen.dart' show WelcomeScreen, WelcomeScreenChannel;
 export 'src/models/guild/scheduled_event.dart' show EntityMetadata, PartialScheduledEvent, ScheduledEvent, ScheduledEventUser, EventStatus, ScheduledEntityType;
+export 'src/models/application.dart'
+    show Application, ApplicationFlags, InstallationParameters, PartialApplication, ApplicationRoleConnectionMetadata, ConnectionMetadataType;
 export 'src/models/guild/template.dart' show GuildTemplate;
 export 'src/models/guild/auto_moderation.dart'
     show
diff --git a/lib/src/gateway/gateway.dart b/lib/src/gateway/gateway.dart
index f7d1e0a34..78986dd8b 100644
--- a/lib/src/gateway/gateway.dart
+++ b/lib/src/gateway/gateway.dart
@@ -293,7 +293,10 @@ class Gateway extends GatewayManager with EventParser {
       gatewayResumeUrl: Uri.parse(raw['resume_gateway_url'] as String),
       shardId: (raw['shard'] as List<Object?>?)?[0] as int?,
       totalShards: (raw['shard'] as List<Object?>?)?[1] as int?,
-      application: PartialApplication(id: Snowflake.parse((raw['application'] as Map<String, Object?>)['id'] as String)),
+      application: PartialApplication(
+        id: Snowflake.parse((raw['application'] as Map<String, Object?>)['id'] as String),
+        manager: client.applications,
+      ),
     );
   }
 
diff --git a/lib/src/http/managers/application_manager.dart b/lib/src/http/managers/application_manager.dart
index 59f7c2f36..313cb2cd3 100644
--- a/lib/src/http/managers/application_manager.dart
+++ b/lib/src/http/managers/application_manager.dart
@@ -1,5 +1,8 @@
 import 'package:nyxx/src/client.dart';
+import 'package:nyxx/src/http/request.dart';
+import 'package:nyxx/src/http/route.dart';
 import 'package:nyxx/src/models/application.dart';
+import 'package:nyxx/src/models/locale.dart';
 import 'package:nyxx/src/models/permissions.dart';
 import 'package:nyxx/src/models/snowflake.dart';
 import 'package:nyxx/src/models/team.dart';
@@ -15,10 +18,14 @@ class ApplicationManager {
   /// Create a new [ApplicationManager].
   ApplicationManager(this.client);
 
+  /// Return a partial application with the given [id].
+  PartialApplication operator [](Snowflake id) => PartialApplication(id: id, manager: this);
+
   /// Parse an [Application] from [raw].
   Application parse(Map<String, Object?> raw) {
     return Application(
       id: Snowflake.parse(raw['id'] as String),
+      manager: this,
       name: raw['name'] as String,
       iconHash: raw['icon'] as String?,
       description: raw['description'] as String,
@@ -72,4 +79,45 @@ class ApplicationManager {
       permissions: Permissions(int.parse(raw['permissions'] as String)),
     );
   }
+
+  ApplicationRoleConnectionMetadata parseApplicationRoleConnectionMetadata(Map<String, Object?> raw) {
+    return ApplicationRoleConnectionMetadata(
+      type: ConnectionMetadataType.parse(raw['type'] as int),
+      key: raw['key'] as String,
+      name: raw['name'] as String,
+      localizedNames: maybeParse(
+        raw['name_localizations'],
+        (Map<String, Object?> raw) => raw.map((key, value) => MapEntry(Locale.parse(key), value as String)),
+      ),
+      description: raw['description'] as String,
+      localizedDescriptions: maybeParse(
+        raw['description_localizations'],
+        (Map<String, Object?> raw) => raw.map((key, value) => MapEntry(Locale.parse(key), value as String)),
+      ),
+    );
+  }
+
+  /// Fetch an application's role connection metadata.
+  Future<List<ApplicationRoleConnectionMetadata>> fetchApplicationRoleConnectionMetadata(Snowflake id) async {
+    final route = HttpRoute()
+      ..applications(id: id.toString())
+      ..roleConnections()
+      ..metadata();
+    final request = BasicRequest(route);
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseMany(response.jsonBody as List<Object?>, parseApplicationRoleConnectionMetadata);
+  }
+
+  /// Update and fetch an application's role connection metadata.
+  Future<List<ApplicationRoleConnectionMetadata>> updateApplicationRoleConnectionMetadata(Snowflake id) async {
+    final route = HttpRoute()
+      ..applications(id: id.toString())
+      ..roleConnections()
+      ..metadata();
+    final request = BasicRequest(route, method: 'PUT');
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseMany(response.jsonBody as List<Object?>, parseApplicationRoleConnectionMetadata);
+  }
 }
diff --git a/lib/src/http/route.dart b/lib/src/http/route.dart
index c9bf50806..5e3c86a02 100644
--- a/lib/src/http/route.dart
+++ b/lib/src/http/route.dart
@@ -242,6 +242,14 @@ extension RouteHelpers on HttpRoute {
   /// Adds the [`voice-states`](https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state) part to this [HttpRoute].
   void voiceStates({String? id}) => add(HttpRoutePart('voice-states', [if (id != null) HttpRouteParam(id)]));
 
+  /// Adds the [`role-connections`](https://discord.com/developers/docs/resources/application-role-connection-metadata#get-application-role-connection-metadata-records)
+  /// part to this [HttpRoute].
+  void roleConnections() => add(HttpRoutePart('role-connections'));
+
+  /// Adds the [`metadata`](https://discord.com/developers/docs/resources/application-role-connection-metadata#get-application-role-connection-metadata-records)
+  /// part to this [HttpRoute].
+  void metadata() => add(HttpRoutePart('metadata'));
+
   /// Adds the [`templates`](https://discord.com/developers/docs/resources/guild-template#get-guild-template) part to this [HttpRoute].
   void templates({String? code}) => add(HttpRoutePart('templates', [if (code != null) HttpRouteParam(code)]));
 }
diff --git a/lib/src/models/application.dart b/lib/src/models/application.dart
index 33f1227b0..5d159449c 100644
--- a/lib/src/models/application.dart
+++ b/lib/src/models/application.dart
@@ -1,3 +1,5 @@
+import 'package:nyxx/src/http/managers/application_manager.dart';
+import 'package:nyxx/src/models/locale.dart';
 import 'package:nyxx/src/models/permissions.dart';
 import 'package:nyxx/src/models/snowflake.dart';
 import 'package:nyxx/src/models/team.dart';
@@ -12,8 +14,17 @@ class PartialApplication with ToStringHelper {
   /// The ID of this application.
   final Snowflake id;
 
+  /// The manager for this application.
+  final ApplicationManager manager;
+
   /// Create a new [PartialApplication].
-  PartialApplication({required this.id});
+  PartialApplication({required this.id, required this.manager});
+
+  /// Fetch this application's role connection metadata.
+  Future<List<ApplicationRoleConnectionMetadata>> fetchRoleConnectionMetadata() => manager.fetchApplicationRoleConnectionMetadata(id);
+
+  /// Update and fetch this application's role connection metadata.
+  Future<List<ApplicationRoleConnectionMetadata>> updateRoleConnectionMetadata() => manager.updateApplicationRoleConnectionMetadata(id);
 }
 
 /// {@template application}
@@ -85,6 +96,7 @@ class Application extends PartialApplication {
   /// {@macro application}
   Application({
     required super.id,
+    required super.manager,
     required this.name,
     required this.iconHash,
     required this.description,
@@ -190,3 +202,64 @@ class InstallationParameters with ToStringHelper {
     required this.permissions,
   });
 }
+
+/// {@template application_role_connection_metadata}
+/// Metadata for an app's role connections.
+/// {@endtemplate}
+class ApplicationRoleConnectionMetadata with ToStringHelper {
+  /// The type of connection.
+  final ConnectionMetadataType type;
+
+  /// The key of the entry in the metadata to check.
+  final String key;
+
+  /// The name of this role connection.
+  final String name;
+
+  /// A localized map of names for this role connection.
+  final Map<Locale, String>? localizedNames;
+
+  /// A description of this role connection.
+  final String description;
+
+  /// A localized map of descriptions for this role connection.
+  final Map<Locale, String>? localizedDescriptions;
+
+  /// {@macro application_role_connection_metadata}
+  ApplicationRoleConnectionMetadata({
+    required this.type,
+    required this.key,
+    required this.name,
+    required this.localizedNames,
+    required this.description,
+    required this.localizedDescriptions,
+  });
+}
+
+/// The type of an [ApplicationRoleConnectionMetadata].
+enum ConnectionMetadataType {
+  integerLessThanOrEqual._(1),
+  integerGreaterThanOrEqual._(2),
+  integerEqual._(3),
+  integerNotEqual._(4),
+  dateTimeLessThanOrEqual._(5),
+  dateTimeGreaterThanOrEqual._(6),
+  booleanEqual._(7),
+  booleanNotEqual._(8);
+
+  /// The value of this [ConnectionMetadataType].
+  final int value;
+
+  const ConnectionMetadataType._(this.value);
+
+  /// Parse a [ConnectionMetadataType] from an [int].
+  ///
+  /// The [value] must be a valid connection metadata type.
+  factory ConnectionMetadataType.parse(int value) => ConnectionMetadataType.values.firstWhere(
+        (type) => type.value == value,
+        orElse: () => throw FormatException('Unknown connection metadata type', value),
+      );
+
+  @override
+  String toString() => 'ConnectionMetadataType($value)';
+}


Test patch:
diff --git a/test/unit/http/managers/application_manager_test.dart b/test/unit/http/managers/application_manager_test.dart
index 7705b92f5..35e350276 100644
--- a/test/unit/http/managers/application_manager_test.dart
+++ b/test/unit/http/managers/application_manager_test.dart
@@ -3,65 +3,82 @@ import 'package:nyxx/nyxx.dart';
 import 'package:test/test.dart';
 
 import '../../../mocks/client.dart';
+import '../../../test_endpoint.dart';
 import '../../../test_manager.dart';
 
-void main() {
-  final sampleApplication = {
-    "bot_public": true,
-    "bot_require_code_grant": false,
-    "cover_image": "31deabb7e45b6c8ecfef77d2f99c81a5",
-    "description": "Test",
-    "guild_id": "290926798626357260",
-    "icon": null,
-    "id": "172150183260323840",
-    "name": "Baba O-Riley",
-    "owner": {"avatar": null, "discriminator": "1738", "flags": 1024, "id": "172150183260323840", "username": "i own a bot"},
-    "primary_sku_id": "172150183260323840",
-    "slug": "test",
-    "summary": "",
-    "team": {
-      "icon": "dd9b7dcfdf5351b9c3de0fe167bacbe1",
-      "id": "531992624043786253",
-      "members": [
-        {
-          "membership_state": 2,
-          "permissions": ["*"],
-          "team_id": "531992624043786253",
-          "user": {"avatar": "d9e261cd35999608eb7e3de1fae3688b", "discriminator": "0001", "id": "511972282709709995", "username": "Mr Owner"}
-        }
-      ],
+final sampleApplication = {
+  "bot_public": true,
+  "bot_require_code_grant": false,
+  "cover_image": "31deabb7e45b6c8ecfef77d2f99c81a5",
+  "description": "Test",
+  "guild_id": "290926798626357260",
+  "icon": null,
+  "id": "172150183260323840",
+  "name": "Baba O-Riley",
+  "owner": {"avatar": null, "discriminator": "1738", "flags": 1024, "id": "172150183260323840", "username": "i own a bot"},
+  "primary_sku_id": "172150183260323840",
+  "slug": "test",
+  "summary": "",
+  "team": {
+    "icon": "dd9b7dcfdf5351b9c3de0fe167bacbe1",
+    "id": "531992624043786253",
+    "members": [
+      {
+        "membership_state": 2,
+        "permissions": ["*"],
+        "team_id": "531992624043786253",
+        "user": {"avatar": "d9e261cd35999608eb7e3de1fae3688b", "discriminator": "0001", "id": "511972282709709995", "username": "Mr Owner"}
+      }
+    ],
+
+    // The docs say these fields are present, but they aren't in the sample application Discord provides
+    "name": "test team",
+    "owner_user_id": "0",
+  },
+  "verify_key": "1e0a356058d627ca38a5c8c9648818061d49e49bd9da9e3ab17d98ad4d6bg2u8"
+};
+
+void checkApplication(Application application) {
+  expect(application.id, equals(Snowflake(172150183260323840)));
+  expect(application.name, equals('Baba O-Riley'));
+  expect(application.iconHash, isNull);
+  expect(application.description, equals('Test'));
+  expect(application.rpcOrigins, isNull);
+  expect(application.isBotPublic, isTrue);
+  expect(application.botRequiresCodeGrant, isFalse);
+  expect(application.termsOfServiceUrl, isNull);
+  expect(application.privacyPolicyUrl, isNull);
+  expect(application.owner?.id, equals(Snowflake(172150183260323840)));
+  expect(application.verifyKey, equals('1e0a356058d627ca38a5c8c9648818061d49e49bd9da9e3ab17d98ad4d6bg2u8'));
+  expect(application.team?.id, equals(Snowflake(531992624043786253)));
+  expect(application.guildId, equals(Snowflake(290926798626357260)));
+  expect(application.primarySkuId, equals(Snowflake(172150183260323840)));
+  expect(application.slug, equals('test'));
+  expect(application.coverImageHash, equals('31deabb7e45b6c8ecfef77d2f99c81a5'));
+  expect(application.flags, equals(ApplicationFlags(0)));
+  expect(application.tags, isNull);
+  expect(application.installationParameters, isNull);
+  expect(application.customInstallUrl, isNull);
+  expect(application.roleConnectionsVerificationUrl, isNull);
+}
 
-      // The docs say these fields are present, but they aren't in the sample application Discord provides
-      "name": "test team",
-      "owner_user_id": "0",
-    },
-    "verify_key": "1e0a356058d627ca38a5c8c9648818061d49e49bd9da9e3ab17d98ad4d6bg2u8"
-  };
+final sampleRoleConnectionMetadata = {
+  'type': 1,
+  'key': 'key',
+  'name': 'test name',
+  'description': 'test description',
+};
 
-  void checkApplication(Application application) {
-    expect(application.id, equals(Snowflake(172150183260323840)));
-    expect(application.name, equals('Baba O-Riley'));
-    expect(application.iconHash, isNull);
-    expect(application.description, equals('Test'));
-    expect(application.rpcOrigins, isNull);
-    expect(application.isBotPublic, isTrue);
-    expect(application.botRequiresCodeGrant, isFalse);
-    expect(application.termsOfServiceUrl, isNull);
-    expect(application.privacyPolicyUrl, isNull);
-    expect(application.owner?.id, equals(Snowflake(172150183260323840)));
-    expect(application.verifyKey, equals('1e0a356058d627ca38a5c8c9648818061d49e49bd9da9e3ab17d98ad4d6bg2u8'));
-    expect(application.team?.id, equals(Snowflake(531992624043786253)));
-    expect(application.guildId, equals(Snowflake(290926798626357260)));
-    expect(application.primarySkuId, equals(Snowflake(172150183260323840)));
-    expect(application.slug, equals('test'));
-    expect(application.coverImageHash, equals('31deabb7e45b6c8ecfef77d2f99c81a5'));
-    expect(application.flags, equals(ApplicationFlags(0)));
-    expect(application.tags, isNull);
-    expect(application.installationParameters, isNull);
-    expect(application.customInstallUrl, isNull);
-    expect(application.roleConnectionsVerificationUrl, isNull);
-  }
+void checkRoleConnectionMetadata(ApplicationRoleConnectionMetadata metadata) {
+  expect(metadata.type, equals(ConnectionMetadataType.integerLessThanOrEqual));
+  expect(metadata.key, equals('key'));
+  expect(metadata.name, equals('test name'));
+  expect(metadata.localizedNames, isNull);
+  expect(metadata.description, equals('test description'));
+  expect(metadata.localizedDescriptions, isNull);
+}
 
+void main() {
   group('ApplicationManager', () {
     test('parse', () {
       final client = MockNyxx();
@@ -75,5 +92,33 @@ void main() {
         check: checkApplication,
       ).runWithManager(ApplicationManager(client));
     });
+
+    test('parseApplicationRoleConnectionMetadata', () {
+      final client = MockNyxx();
+      when(() => client.apiOptions).thenReturn(RestApiOptions(token: 'TEST_TOKEN'));
+      when(() => client.options).thenReturn(RestClientOptions());
+
+      ParsingTest<ApplicationManager, ApplicationRoleConnectionMetadata, Map<String, Object?>>(
+        name: 'parseApplicationRoleConnectionMetadata',
+        source: sampleRoleConnectionMetadata,
+        parse: (manager) => manager.parseApplicationRoleConnectionMetadata,
+        check: checkRoleConnectionMetadata,
+      ).runWithManager(ApplicationManager(client));
+    });
+
+    testEndpoint(
+      '/applications/0/role-connections/metadata',
+      name: 'fetchApplicationRoleConnectionMetadata',
+      (client) => client.applications.fetchApplicationRoleConnectionMetadata(Snowflake.zero),
+      response: [sampleRoleConnectionMetadata],
+    );
+
+    testEndpoint(
+      '/applications/0/role-connections/metadata',
+      method: 'PUT',
+      name: 'updateApplicationRoleConnectionMetadata',
+      (client) => client.applications.updateApplicationRoleConnectionMetadata(Snowflake.zero),
+      response: [sampleRoleConnectionMetadata],
+    );
   });
 }