Repository: nyxx-discord/nyxx (dart)

Issue:
[rewrite] Implement HTTP Guild Template API
Ref: https://discord.com/developers/docs/resources/guild-template

Pull request:
# Description

Rewrite the Guild Template HTTP API

## Connected issues & other potential problems

Closes #461

## 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 3ecce8ff7..963617cb1 100644
--- a/lib/nyxx.dart
+++ b/lib/nyxx.dart
@@ -37,6 +37,7 @@ export 'src/builders/guild/member.dart' show CurrentMemberUpdateBuilder, MemberB
 export 'src/builders/guild/welcome_screen.dart' show WelcomeScreenUpdateBuilder;
 export 'src/builders/guild/widget.dart' show WidgetSettingsUpdateBuilder;
 export 'src/builders/guild/scheduled_event.dart' show ScheduledEventBuilder, ScheduledEventUpdateBuilder;
+export 'src/builders/guild/template.dart' show GuildTemplateBuilder, GuildTemplateUpdateBuilder;
 export 'src/builders/guild/auto_moderation.dart' show AutoModerationRuleBuilder, AutoModerationRuleUpdateBuilder;
 export 'src/builders/role.dart' show RoleBuilder, RoleUpdateBuilder;
 export 'src/builders/voice.dart' show CurrentUserVoiceStateUpdateBuilder, VoiceStateUpdateBuilder, GatewayVoiceStateBuilder;
@@ -127,6 +128,7 @@ 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/guild/template.dart' show GuildTemplate;
 export 'src/models/guild/auto_moderation.dart'
     show
         ActionMetadata,
diff --git a/lib/src/builders/guild/template.dart b/lib/src/builders/guild/template.dart
new file mode 100644
index 000000000..5057ba55f
--- /dev/null
+++ b/lib/src/builders/guild/template.dart
@@ -0,0 +1,31 @@
+import 'package:nyxx/src/builders/builder.dart';
+import 'package:nyxx/src/builders/sentinels.dart';
+import 'package:nyxx/src/models/guild/template.dart';
+
+class GuildTemplateBuilder extends CreateBuilder<GuildTemplate> {
+  final String name;
+
+  final String? description;
+
+  GuildTemplateBuilder({required this.name, this.description});
+
+  @override
+  Map<String, Object?> build() => {
+        'name': name,
+        if (description != null) 'description': description,
+      };
+}
+
+class GuildTemplateUpdateBuilder extends UpdateBuilder<GuildTemplate> {
+  final String? name;
+
+  final String? description;
+
+  GuildTemplateUpdateBuilder({this.name, this.description = sentinelString});
+
+  @override
+  Map<String, Object?> build() => {
+        if (name != null) 'name': name,
+        if (!identical(description, sentinelString)) 'description': description,
+      };
+}
diff --git a/lib/src/http/managers/guild_manager.dart b/lib/src/http/managers/guild_manager.dart
index 9faf3dc57..e019eded8 100644
--- a/lib/src/http/managers/guild_manager.dart
+++ b/lib/src/http/managers/guild_manager.dart
@@ -4,8 +4,10 @@ import 'dart:typed_data';
 import 'package:nyxx/src/builders/channel/channel_position.dart';
 import 'package:nyxx/src/builders/channel/guild_channel.dart';
 import 'package:nyxx/src/builders/guild/guild.dart';
+import 'package:nyxx/src/builders/guild/template.dart';
 import 'package:nyxx/src/builders/guild/welcome_screen.dart';
 import 'package:nyxx/src/builders/guild/widget.dart';
+import 'package:nyxx/src/builders/image.dart';
 import 'package:nyxx/src/builders/voice.dart';
 import 'package:nyxx/src/cache/cache.dart';
 import 'package:nyxx/src/http/managers/manager.dart';
@@ -20,6 +22,7 @@ import 'package:nyxx/src/models/guild/guild_preview.dart';
 import 'package:nyxx/src/models/guild/guild_widget.dart';
 import 'package:nyxx/src/models/guild/integration.dart';
 import 'package:nyxx/src/models/guild/onboarding.dart';
+import 'package:nyxx/src/models/guild/template.dart';
 import 'package:nyxx/src/models/guild/welcome_screen.dart';
 import 'package:nyxx/src/models/locale.dart';
 import 'package:nyxx/src/models/permissions.dart';
@@ -285,6 +288,35 @@ class GuildManager extends Manager<Guild> {
     );
   }
 
+  GuildTemplate parseGuildTemplate(Map<String, Object?> raw) {
+    final sourceGuildId = Snowflake.parse(raw['source_guild_id'] as String);
+
+    return GuildTemplate(
+      code: raw['code'] as String,
+      manager: this,
+      name: raw['name'] as String,
+      description: raw['description'] as String?,
+      usageCount: raw['usage_count'] as int,
+      creatorId: Snowflake.parse(raw['creator_id'] as String),
+      creator: client.users.parse(raw['creator'] as Map<String, Object?>),
+      createdAt: DateTime.parse(raw['created_at'] as String),
+      updatedAt: DateTime.parse(raw['updated_at'] as String),
+      sourceGuildId: sourceGuildId,
+      // Add synthetic fields so we can parse the (mostly complete) partial guild as a full guild
+      serializedSourceGuild: parse({
+        'id': sourceGuildId.toString(),
+        'owner_id': Snowflake.zero.toString(),
+        'features': [],
+        'mfa_level': MfaLevel.none.value,
+        'premium_tier': PremiumTier.none.value,
+        'nsfw_level': NsfwLevel.unset.value,
+        'premium_progress_bar_enabled': false,
+        ...(raw['serialized_source_guild'] as Map<String, Object?>),
+      }),
+      isDirty: raw['is_dirty'] as bool?,
+    );
+  }
+
   @override
   Future<Guild> fetch(Snowflake id, {bool? withCounts}) async {
     final route = HttpRoute()..guilds(id: id.toString());
@@ -643,4 +675,84 @@ class GuildManager extends Manager<Guild> {
 
     await client.httpHandler.executeSafe(request);
   }
+
+  /// Fetch a guild template by [code].
+  Future<GuildTemplate> fetchGuildTemplate(String code) async {
+    final route = HttpRoute()
+      ..guilds()
+      ..templates(code: code);
+    final request = BasicRequest(route);
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseGuildTemplate(response.jsonBody as Map<String, Object?>);
+  }
+
+  /// Create a guild from a guild template.
+  Future<Guild> createGuildFromTemplate(String code, {required String name, ImageBuilder? icon}) async {
+    final route = HttpRoute()
+      ..guilds()
+      ..templates(code: code);
+    final request = BasicRequest(route, method: 'POST', body: jsonEncode({'name': name, if (icon != null) 'icon': icon.build()}));
+
+    final response = await client.httpHandler.executeSafe(request);
+    final guild = parse(response.jsonBody as Map<String, Object?>);
+
+    cache[guild.id] = guild;
+    return guild;
+  }
+
+  /// List the templates in a guild.
+  Future<List<GuildTemplate>> listGuildTemplates(Snowflake id) async {
+    final route = HttpRoute()
+      ..guilds(id: id.toString())
+      ..templates();
+    final request = BasicRequest(route);
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseMany(response.jsonBody as List<Object?>, parseGuildTemplate);
+  }
+
+  /// Create a guild template from a guild.
+  Future<GuildTemplate> createGuildTemplate(Snowflake id, GuildTemplateBuilder builder) async {
+    final route = HttpRoute()
+      ..guilds(id: id.toString())
+      ..templates();
+    final request = BasicRequest(route, method: 'POST', body: jsonEncode(builder.build()));
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseGuildTemplate(response.jsonBody as Map<String, Object?>);
+  }
+
+  /// Sync a guild template to the source guild.
+  Future<GuildTemplate> syncGuildTemplate(Snowflake id, String code) async {
+    final route = HttpRoute()
+      ..guilds(id: id.toString())
+      ..templates(code: code);
+    final request = BasicRequest(route, method: 'PUT');
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseGuildTemplate(response.jsonBody as Map<String, Object?>);
+  }
+
+  /// Update a guild template.
+  Future<GuildTemplate> updateGuildTemplate(Snowflake id, String code, GuildTemplateUpdateBuilder builder) async {
+    final route = HttpRoute()
+      ..guilds(id: id.toString())
+      ..templates(code: code);
+    final request = BasicRequest(route, method: 'PATCH', body: jsonEncode(builder.build()));
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseGuildTemplate(response.jsonBody as Map<String, Object?>);
+  }
+
+  /// Delete a guild template.
+  Future<GuildTemplate> deleteGuildTemplate(Snowflake id, String code) async {
+    final route = HttpRoute()
+      ..guilds(id: id.toString())
+      ..templates(code: code);
+    final request = BasicRequest(route, method: 'DELETE');
+
+    final response = await client.httpHandler.executeSafe(request);
+    return parseGuildTemplate(response.jsonBody as Map<String, Object?>);
+  }
 }
diff --git a/lib/src/http/route.dart b/lib/src/http/route.dart
index 882acf22e..c9bf50806 100644
--- a/lib/src/http/route.dart
+++ b/lib/src/http/route.dart
@@ -241,4 +241,7 @@ 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 [`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/guild/guild.dart b/lib/src/models/guild/guild.dart
index 3a0db8529..b622db16b 100644
--- a/lib/src/models/guild/guild.dart
+++ b/lib/src/models/guild/guild.dart
@@ -2,6 +2,7 @@ import 'dart:typed_data';
 
 import 'package:nyxx/src/builders/channel/channel_position.dart';
 import 'package:nyxx/src/builders/channel/guild_channel.dart';
+import 'package:nyxx/src/builders/guild/template.dart';
 import 'package:nyxx/src/builders/guild/welcome_screen.dart';
 import 'package:nyxx/src/builders/guild/widget.dart';
 import 'package:nyxx/src/builders/voice.dart';
@@ -17,6 +18,7 @@ import 'package:nyxx/src/models/guild/guild_preview.dart';
 import 'package:nyxx/src/models/guild/guild_widget.dart';
 import 'package:nyxx/src/models/guild/integration.dart';
 import 'package:nyxx/src/models/guild/onboarding.dart';
+import 'package:nyxx/src/models/guild/template.dart';
 import 'package:nyxx/src/models/guild/welcome_screen.dart';
 import 'package:nyxx/src/models/locale.dart';
 import 'package:nyxx/src/models/permissions.dart';
@@ -131,6 +133,21 @@ class PartialGuild extends WritableSnowflakeEntity<Guild> {
 
   /// Update a member's voice state in this guild.
   Future<void> updateVoiceState(Snowflake userId, VoiceStateUpdateBuilder builder) => manager.updateVoiceState(id, userId, builder);
+
+  /// List the templates in this guild.
+  Future<List<GuildTemplate>> listTemplates() => manager.listGuildTemplates(id);
+
+  /// Create a template in this guild.
+  Future<GuildTemplate> createTemplate(GuildTemplateBuilder builder) => manager.createGuildTemplate(id, builder);
+
+  /// Sync a template in this guild.
+  Future<GuildTemplate> syncTemplate(String code) => manager.syncGuildTemplate(id, code);
+
+  /// Update a template in this guild.
+  Future<GuildTemplate> updateTemplate(String code, GuildTemplateUpdateBuilder builder) => manager.updateGuildTemplate(id, code, builder);
+
+  /// Delete a template in this guild.
+  Future<GuildTemplate> deleteTemplate(String code) => manager.deleteGuildTemplate(id, code);
 }
 
 /// {@template guild}
diff --git a/lib/src/models/guild/template.dart b/lib/src/models/guild/template.dart
new file mode 100644
index 000000000..8496b0ed2
--- /dev/null
+++ b/lib/src/models/guild/template.dart
@@ -0,0 +1,79 @@
+import 'package:nyxx/src/builders/guild/template.dart';
+import 'package:nyxx/src/builders/image.dart';
+import 'package:nyxx/src/http/managers/guild_manager.dart';
+import 'package:nyxx/src/models/guild/guild.dart';
+import 'package:nyxx/src/models/snowflake.dart';
+import 'package:nyxx/src/models/user/user.dart';
+import 'package:nyxx/src/utils/to_string_helper/to_string_helper.dart';
+
+/// {@template guild_template}
+/// A snapshot of a [Guild] that can be used to create a new guild.
+/// {@endtemplate}
+class GuildTemplate with ToStringHelper {
+  /// The code of this template.
+  final String code;
+
+  /// The manager for this template.
+  final GuildManager manager;
+
+  /// The name of this template.
+  final String name;
+
+  /// The description of this template.
+  final String? description;
+
+  /// The number of times this template was used.
+  final int usageCount;
+
+  /// The ID of the user that created this template.
+  final Snowflake creatorId;
+
+  /// The user that created this template.
+  final User creator;
+
+  /// The time at which this template was created.
+  final DateTime createdAt;
+
+  /// The time at which this template was last updated.
+  final DateTime updatedAt;
+
+  /// The ID of the guild this template was created from.
+  final Snowflake sourceGuildId;
+
+  /// The snapshot of the guild that will be used for this template.
+  final Guild serializedSourceGuild;
+
+  /// Whether this template has unsynced changes.
+  final bool? isDirty;
+
+  /// {@macro guild_template}
+  GuildTemplate({
+    required this.code,
+    required this.manager,
+    required this.name,
+    required this.description,
+    required this.usageCount,
+    required this.creatorId,
+    required this.creator,
+    required this.createdAt,
+    required this.updatedAt,
+    required this.sourceGuildId,
+    required this.serializedSourceGuild,
+    required this.isDirty,
+  });
+
+  /// Create a guild from this template.
+  Future<Guild> use({required String name, ImageBuilder? icon}) => manager.createGuildFromTemplate(code, name: name, icon: icon);
+
+  /// Fetch this template.
+  Future<GuildTemplate> fetch() => manager.fetchGuildTemplate(code);
+
+  /// Sync this template to the source guild.
+  Future<GuildTemplate> sync() => manager.syncGuildTemplate(sourceGuildId, code);
+
+  /// Update this template.
+  Future<GuildTemplate> update(GuildTemplateUpdateBuilder builder) => manager.updateGuildTemplate(sourceGuildId, code, builder);
+
+  /// Delete this template.
+  Future<GuildTemplate> delete() => manager.deleteGuildTemplate(sourceGuildId, code);
+}


Test patch:
diff --git a/test/unit/http/managers/guild_manager_test.dart b/test/unit/http/managers/guild_manager_test.dart
index 7b136551c..1e09a672f 100644
--- a/test/unit/http/managers/guild_manager_test.dart
+++ b/test/unit/http/managers/guild_manager_test.dart
@@ -449,6 +449,88 @@ void checkIntegration(Integration integration) {
   expect(integration.scopes, isNull);
 }
 
+final sampleGuildTemplate = {
+  "code": "hgM48av5Q69A",
+  "name": "Friends & Family",
+  "description": "",
+  "usage_count": 49605,
+  "creator_id": "132837293881950208",
+  "creator": {"id": "132837293881950208", "username": "hoges", "avatar": "79b0d9f8c340f2d43e1f78b09f175b62", "discriminator": "0001", "public_flags": 129},
+  "created_at": "2020-04-02T21:10:38+00:00",
+  "updated_at": "2020-05-01T17:57:38+00:00",
+  "source_guild_id": "678070694164299796",
+  "serialized_source_guild": {
+    "name": "Friends & Family",
+    "description": null,
+    "region": "us-west",
+    "verification_level": 0,
+    "default_message_notifications": 0,
+    "explicit_content_filter": 0,
+    "preferred_locale": "en-US",
+    "afk_timeout": 300,
+    "roles": [
+      {
+        "id": '0',
+        "name": "@everyone",
+        "permissions": "104324689",
+        "color": 0,
+        "hoist": false,
+        "mentionable": false,
+
+        // TODO: Is this included in real templates?
+        "position": 1,
+      }
+    ],
+    "channels": [
+      {
+        "name": "Text Channels",
+        "position": 1,
+        "topic": null,
+        "bitrate": 64000,
+        "user_limit": 0,
+        "nsfw": false,
+        "rate_limit_per_user": 0,
+        "parent_id": null,
+        "permission_overwrites": [],
+        "id": '1',
+        "type": 4
+      },
+      {
+        "name": "general",
+        "position": 1,
+        "topic": null,
+        "bitrate": 64000,
+        "user_limit": 0,
+        "nsfw": false,
+        "rate_limit_per_user": 0,
+        "parent_id": 1,
+        "permission_overwrites": [],
+        "id": '2',
+        "type": 0
+      }
+    ],
+    "afk_channel_id": null,
+    "system_channel_id": '2',
+    "system_channel_flags": 0,
+    "icon_hash": null
+  },
+  "is_dirty": null
+};
+
+void checkGuildTemplate(GuildTemplate template) {
+  expect(template.code, equals('hgM48av5Q69A'));
+  expect(template.name, equals('Friends & Family'));
+  expect(template.description, equals(''));
+  expect(template.usageCount, equals(49605));
+  expect(template.creatorId, equals(Snowflake(132837293881950208)));
+  expect(template.creator.id, equals(Snowflake(132837293881950208)));
+  expect(template.createdAt, equals(DateTime.utc(2020, 04, 02, 21, 10, 38)));
+  expect(template.updatedAt, equals(DateTime.utc(2020, 05, 01, 17, 57, 38)));
+  expect(template.sourceGuildId, equals(Snowflake(678070694164299796)));
+  expect(template.serializedSourceGuild.name, equals('Friends & Family'));
+  expect(template.isDirty, isNull);
+}
+
 void main() {
   testManager<Guild, GuildManager>(
     'GuildManager',
@@ -496,6 +578,12 @@ void main() {
         parse: (manager) => manager.parseOnboarding,
         check: checkOnboarding,
       ),
+      ParsingTest<GuildManager, GuildTemplate, Map<String, Object?>>(
+        name: 'parseGuildTemplate',
+        source: sampleGuildTemplate,
+        parse: (manager) => manager.parseGuildTemplate,
+        check: checkGuildTemplate,
+      ),
     ],
     additionalEndpointTests: [
       EndpointTest<GuildManager, GuildPreview, Map<String, Object?>>(
@@ -698,6 +786,63 @@ void main() {
         execute: (manager) => manager.updateVoiceState(Snowflake.zero, Snowflake.zero, VoiceStateUpdateBuilder()),
         check: (_) {},
       ),
+      EndpointTest<GuildManager, GuildTemplate, Map<String, Object?>>(
+        name: 'fetchGuildTemplate',
+        source: sampleGuildTemplate,
+        urlMatcher: '/guilds/templates/test',
+        execute: (manager) => manager.fetchGuildTemplate('test'),
+        check: checkGuildTemplate,
+      ),
+      EndpointTest<GuildManager, Guild, Map<String, Object?>>(
+        name: 'createGuildFromTemplate',
+        method: 'POST',
+        source: sampleGuild,
+        urlMatcher: '/guilds/templates/test',
+        execute: (manager) => manager.createGuildFromTemplate('test', name: 'test guild'),
+        check: checkGuild,
+      ),
+      EndpointTest<GuildManager, List<GuildTemplate>, List<Object?>>(
+        name: 'listGuildTemplates',
+        source: [sampleGuildTemplate],
+        urlMatcher: '/guilds/0/templates',
+        execute: (manager) => manager.listGuildTemplates(Snowflake.zero),
+        check: (list) {
+          expect(list, hasLength(1));
+          checkGuildTemplate(list.single);
+        },
+      ),
+      EndpointTest<GuildManager, GuildTemplate, Map<String, Object?>>(
+        name: 'createGuildTemplate',
+        method: 'POST',
+        source: sampleGuildTemplate,
+        urlMatcher: '/guilds/0/templates',
+        execute: (manager) => manager.createGuildTemplate(Snowflake.zero, GuildTemplateBuilder(name: 'test')),
+        check: checkGuildTemplate,
+      ),
+      EndpointTest<GuildManager, GuildTemplate, Map<String, Object?>>(
+        name: 'syncGuildTemplate',
+        method: 'PUT',
+        source: sampleGuildTemplate,
+        urlMatcher: '/guilds/0/templates/test',
+        execute: (manager) => manager.syncGuildTemplate(Snowflake.zero, 'test'),
+        check: checkGuildTemplate,
+      ),
+      EndpointTest<GuildManager, GuildTemplate, Map<String, Object?>>(
+        name: 'updateGuildTemplate',
+        method: 'PATCH',
+        source: sampleGuildTemplate,
+        urlMatcher: '/guilds/0/templates/test',
+        execute: (manager) => manager.updateGuildTemplate(Snowflake.zero, 'test', GuildTemplateUpdateBuilder()),
+        check: checkGuildTemplate,
+      ),
+      EndpointTest<GuildManager, GuildTemplate, Map<String, Object?>>(
+        name: 'deleteGuildTemplate',
+        method: 'DELETE',
+        source: sampleGuildTemplate,
+        urlMatcher: '/guilds/0/templates/test',
+        execute: (manager) => manager.deleteGuildTemplate(Snowflake.zero, 'test'),
+        check: checkGuildTemplate,
+      ),
     ],
     createBuilder: GuildBuilder(name: 'Test guild'),
     updateBuilder: GuildUpdateBuilder(),