Vapor 4 versus Django REST Framework

Over two years ago I wrote an article where I compared Vapor 3 to Django REST Framework. Back then I was building a REST API for a side project using Vapor 3, got stuck with some problems and decided to try to build the same system using DRF, and to compare the two. At the end of the article I came to the conclusion that there wasn’t a clear winner; both had their pros and cons. So which one did I end up choosing?

Neither.

Nope, I went with Firebase’s Firestore instead, because it offered real time synching without having to implement my own solution with WebSockets. My side project has been doing pretty good for the past two years running on Firebase, but I want to move away from it. Their JavaScript SDK is huge and doesn’t really work well with SvelteKit and server side rendering, which is something I really want to use to improve the initial page load, which is currently pretty bad. There are too many spinners and other loading indicators involved in different parts of the UI, because all the content has to be fetched asynchronously in the browser. Having the entire page served ready-to-go from the server right away (using server side rendering) would be a massive improvement. The privacy implications of ditching Firebase are also a good motivator.

So, I’m back to that old question: do I use Vapor (which has now reached version 4 and has an async-await branch for Swift 5.5) or Django REST Framework?

This time I’ve written the code for only one the features of my project, first in Vapor and then in DRF. Let’s look at this feature: the models, model migrations and the view code (router logic and the controller) for managing D&D campaigns. Campaigns have members, and each member can have a role (player or DM), so we’re dealing with a many-to-many relationship that needs to store an extra field.

Models

Django

class User(models.Model):
    name = models.CharField(max_length=50)
    email = models.EmailField(blank=True, null=True, unique=True)
    avatar = models.URLField(blank=True, null=True)
    avatar_crop = models.URLField(blank=True, null=True)
    subscribed_until = models.DateTimeField(blank=True, null=True, default=None)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

class Campaign(models.Model):
    name = models.CharField(max_length=50)
    description = models.TextField(blank=True)
    backgroundImage = models.CharField(max_length=255)
    is_private = models.BooleanField(default=True, db_index=True)
    is_featured = models.BooleanField(default=False, db_index=True)
    owner = models.ForeignKey(User, related_name='owned_campaigns', on_delete=models.CASCADE)
    members = models.ManyToManyField(User, related_name='campaigns', through='Membership')
    calendar = models.CharField(max_length=50)
    starting_year = models.IntegerField()
    months = models.JSONField()
    invite_code = models.CharField(max_length=50, unique=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

class Membership(models.Model):
    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    is_dm = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Vapor

final class User: Model, Content {
  struct FieldKeys {
    static var avatar: FieldKey { "avatar" }
    static var avatarCrop: FieldKey { "avatar_crop" }
    static var appleToken: FieldKey { "appleToken" }
    static var email: FieldKey { "email" }
    static var name: FieldKey { "name" }
    static var subscribedUntil: FieldKey { "subscribed_until" }
    static var createdAt: FieldKey { "created_at" }
    static var updatedAt: FieldKey { "updated_at" }
  }

  static let schema = "users"

  @ID(key: .id)
  var id: UUID?

  @OptionalField(key: FieldKeys.avatar)
  var avatar: String?

  @OptionalField(key: FieldKeys.avatarCrop)
  var avatarCrop: String?

  @Field(key: FieldKeys.name)
  var name: String

  @OptionalField(key: FieldKeys.email)
  var email: String?

  @OptionalField(key: FieldKeys.appleToken)
  var appleToken: String?

  @OptionalField(key: FieldKeys.subscribedUntil)
  var subscribedUntil: Date?

  @Siblings(through: Member.self, from: \.$user, to: \.$campaign)
  public var campaigns: [Campaign]

  @Timestamp(key: FieldKeys.createdAt, on: .create)
  var createdAt: Date?

  @Timestamp(key: FieldKeys.updatedAt, on: .update)
  var updatedAt: Date?
}

final class Campaign: Model, Content {
  struct FieldKeys {
    static var name: FieldKey { "name" }
    static var description: FieldKey { "description" }
    static var backgroundImage: FieldKey { "background_image" }
    static var isPrivate: FieldKey { "is_private" }
    static var isFeatured: FieldKey { "is_featured" }
    static var owner: FieldKey { "owner_id" }
    static var calendar: FieldKey { "calendar" }
    static var startingYear: FieldKey { "starting_year" }
    static var months: FieldKey { "months" }
    static var inviteCode: FieldKey { "invite_code" }
    static var createdAt: FieldKey { "created_at" }
    static var updatedAt: FieldKey { "updated_at" }
  }

  static let schema = "campaigns"

  @ID(key: .id)
  var id: UUID?

  @Field(key: FieldKeys.name)
  var name: String

  @OptionalField(key: FieldKeys.description)
  var description: String?

  @Field(key: FieldKeys.backgroundImage)
  var backgroundImage: String

  @Field(key: FieldKeys.isPrivate)
  var isPrivate: Bool

  @Field(key: FieldKeys.isFeatured)
  var isFeatured: Bool

  @Parent(key: FieldKeys.owner)
  var owner: User

  @Field(key: FieldKeys.calendar)
  var calendar: String

  @Field(key: FieldKeys.startingYear)
  var startingYear: Int

  @Field(key: FieldKeys.months)
  var months: [Month]

  @Field(key: FieldKeys.inviteCode)
  var inviteCode: String

  @Timestamp(key: FieldKeys.createdAt, on: .create)
  var createdAt: Date?

  @Timestamp(key: FieldKeys.updatedAt, on: .update)
  var updatedAt: Date?

  @Siblings(through: Member.self, from: \.$campaign, to: \.$user)
  public var users: [User]

  @Children(for: \.$campaign)
  var members: [Member]
}

final class Member: Model {
  struct FieldKeys {
    static var campaign: FieldKey { "campaign_id" }
    static var user: FieldKey { "user_id" }
    static var isDm: FieldKey { "is_dm" }
    static var createdAt: FieldKey { "created_at" }
    static var updatedAt: FieldKey { "updated_at" }
  }

  static let schema = "members"

  @ID(key: .id)
  var id: UUID?

  @Parent(key: FieldKeys.campaign)
  var campaign: Campaign

  @Parent(key: FieldKeys.user)
  var user: User

  @Field(key: FieldKeys.isDm)
  var isDm: Bool

  @Timestamp(key: FieldKeys.createdAt, on: .create)
  var createdAt: Date?

  @Timestamp(key: FieldKeys.updatedAt, on: .update)
  var updatedAt: Date?

  init() { }
}

Winner: a big win for Django. Not only because there is a lot less repeating of strings and field names, but also because we get to specify things like db_index, unique, and auto_now_add directly in the fields. With Vapor that is done in the migrations, which brings us to..

Migrations

Django

It’s all done automatically. When you create or change your models, you run manage.py makemigrations to create the migration definitions, and then run manage.py migrate to apply them to the database. It couldn’t be more simple. It’s one of the best features of Django.

Vapor

Sadly, a lot of work is involved.

struct CreateUserMigration: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    database.schema(User.schema)
      .id()
      .field(User.FieldKeys.avatar, .string)
      .field(User.FieldKeys.avatarCrop, .string)
      .field(User.FieldKeys.name, .string, .required)
      .field(User.FieldKeys.email, .string)
      .field(User.FieldKeys.appleToken, .string)
      .field(User.FieldKeys.subscribedUntil, .datetime)
      .field(User.FieldKeys.createdAt, .datetime, .required)
      .field(User.FieldKeys.updatedAt, .datetime)
      .unique(on: User.FieldKeys.email)
      .unique(on: User.FieldKeys.appleToken)
      .create()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema(User.schema)
      .delete()
  }
}

struct CreateCampaignMigration: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    database.schema(Campaign.schema)
      .id()
      .field(Campaign.FieldKeys.name, .string, .required)
      .field(Campaign.FieldKeys.description, .string)
      .field(Campaign.FieldKeys.backgroundImage, .string, .required)
      .field(Campaign.FieldKeys.isPrivate, .bool, .required)
      .field(Campaign.FieldKeys.owner, .uuid, .required, .references(User.schema, "id", onDelete: .cascade))
      .field(Campaign.FieldKeys.calendar, .string, .required)
      .field(Campaign.FieldKeys.startingYear, .int, .required)
      .field(Campaign.FieldKeys.months, .array(of: .dictionary), .required)
      .field(Campaign.FieldKeys.inviteCode, .string, .required)
      .field(Campaign.FieldKeys.createdAt, .datetime, .required)
      .field(Campaign.FieldKeys.updatedAt, .datetime)
      .unique(on: Campaign.FieldKeys.inviteCode)
      .create()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema(Campaign.schema)
      .delete()
  }
}

struct AddIsFeaturedMigration: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    database.schema(Campaign.schema)
      .field(Campaign.FieldKeys.isFeatured, .bool, .required, .sql(.default(false)))
      .update()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema(Campaign.schema)
      .deleteField(Campaign.FieldKeys.isFeatured)
      .update()
  }
}

struct AddIsFeaturedIndexToCampaign: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    let sqlDB = (database as! SQLDatabase)
    
    return sqlDB
      .create(index: "is_featured_idx")
      .on(Campaign.schema)
      .column("is_featured")
      .run()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    let sqlDB = (database as! SQLDatabase)
    return sqlDB
      .drop(index: "is_featured_idx")
      .run()
  }
}

struct AddIsPrivateIndexToCampaign: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    let sqlDB = (database as! SQLDatabase)

    return sqlDB
      .create(index: "is_private_idx")
      .on(Campaign.schema)
      .column("is_private")
      .run()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    let sqlDB = (database as! SQLDatabase)
    return sqlDB
      .drop(index: "is_private_idx")
      .run()
  }
}

struct CreateMemberMigration: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    database.schema(Member.schema)
      .id()
      .field(Member.FieldKeys.campaign, .uuid, .required, .references("campaigns", "id", onDelete: .cascade))
      .field(Member.FieldKeys.user, .uuid, .required, .references("users", "id", onDelete: .cascade))
      .field(Member.FieldKeys.isDm, .bool, .required)
      .field(Member.FieldKeys.createdAt, .datetime, .required)
      .field(Member.FieldKeys.updatedAt, .datetime)
      .unique(on: Member.FieldKeys.campaign, Member.FieldKeys.user)
      .create()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema(Member.schema)
      .delete()
  }
}

That is an example of only three database tables: users, campaigns and members, plus I added the isFeatured and isPrivate fields to the campaigns table as a new migration. All that code is written by hand, and it’s a drag. The repetition of writing models and their migrations is demotivating.

Winner: Django, with a HUGE margin.

Views

Let’s take a look at 3 endpoints: GET /campaigns, POST /campaigns and GET /campaigns/[id]. One thing to note is that the public representation of a campaign (the response of these endpoints) is not the same as the actual Campaign database model. There are some fields that we don’t want to include, for example the is_featured field, or for the owner and the members we definitely don’t want to include all user model fields: the email address for example is private. So how do both frameworks make this possible?

Django

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'name', 'avatar', 'avatar_crop', 'subscribed_until']

class MembershipSerializer(serializers.ModelSerializer):
    user = UserSerializer()

    class Meta:
        model = Membership
        fields = ['is_dm', 'user']

class CampaignSerializer(serializers.ModelSerializer):
    owner = UserSerializer()
    members = MembershipSerializer(many=True, read_only=True, source='membership_set')

    class Meta:
        model = Campaign
        exclude = ['is_featured', 'calendar']

class CreateCampaignPayload(serializers.ModelSerializer):
    calendar_id = serializers.IntegerField(read_only=True)

    class Meta:
        model = Campaign
        exclude = ['owner', 'members', 'months', 'invite_code', 'calendar']

class CampaignsController(viewsets.ModelViewSet):
    permission_classes = (_CampaignPermission,)

    def get_queryset(self):
        if self.kwargs.get('pk'):
            return Campaign.objects.all()\
                .select_related('owner') \
                .prefetch_related(
                    Prefetch('membership_set', queryset=Membership.objects.all().select_related('user'))
                )

        return self.request.user\
            .campaigns\
            .select_related('owner') \
            .prefetch_related(
                Prefetch('membership_set', queryset=Membership.objects.all().select_related('user'))
            )

    def list(self, request, *args, **kwargs):
        self.serializer_class = CampaignSerializer
        return super(CampaignsController, self).list(request, *args, **kwargs)

    def retrieve(self, request, *args, **kwargs):
        self.serializer_class = CampaignSerializer
        return super(CampaignsController, self).retrieve(request, *args, **kwargs)

    def create(self, request, *args, **kwargs):
        # Create the campaign
        payload_serializer = CreateCampaignPayload(data=request.data)
        payload_serializer.is_valid(raise_exception=True)
        campaign = self.perform_create(payload_serializer)

        # Add yourself as a member
        membership = Membership(user=request.user, campaign=campaign, is_dm=True)
        membership.save()

        # And return the public representation of the campaign
        response_serializer = CampaignSerializer(campaign, context={'request': request})
        headers = self.get_success_headers(response_serializer.data)
        return Response(response_serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    def perform_create(self, serializer):
        try:
            calendar = Calendar.objects.get(pk=serializer.initial_data.get('calendar_id'))
        except Calendar.DoesNotExist:
            raise serializers.ValidationError('This is not a calendar_id')

        return serializer.save(
            months=calendar.months,
            owner=self.request.user,
            calendar=calendar.name,
            starting_year=calendar.default_starting_year,
            invite_code=uuid.uuid4().hex
        )

Okay, that’s a lot of code, so let’s go through it. At the top are four serializer subclasses: first we have UserSerializer, MembershipSerializer and CampaignSerializer which are used for returning the public representation of users, memberships and campaigns. Plus CreateCampaignPayload which represents the payload that is posted to the server when we want to create a new campaign.

Then there is the CampaignsController which includes all the logic for returning the list of campaigns, a single campaign, and for creating a new campaign.

Hooking all this up to the router is very simple:

router = SimpleRouter(trailing_slash=False)
router.register('/?', CampaignsController, basename='campaign')
urlpatterns = router.urls

Vapor

struct PublicUser: Content {
  let id: UUID
  let avatar: String?
  let avatarCrop: String?
  let name: String
  let email: String?
  let subscribedUntil: Date?
  let createdAt: Date?
  let updatedAt: Date?

  init(from: User) throws {
    try self.id = from.requireID()
    self.avatar = from.avatar
    self.avatarCrop = from.avatarCrop
    self.name = from.name
    self.email = from.email
    self.subscribedUntil = from.subscribedUntil
    self.createdAt = from.createdAt
    self.updatedAt = from.updatedAt
  }
}

struct PublicCampaign: Content {
  let id: UUID
  let name: String
  let description: String?
  let backgroundImage: String
  let isPrivate: Bool
  let calendar: String
  let startingYear: Int
  let months: [Month]
  let createdAt: Date?
  let updatedAt: Date?
  let owner: PublicUser
  let members: [PublicMember]
  let inviteCode: String

  init(from: Campaign) throws {
    try self.id = from.requireID()
    self.name = from.name
    self.description = from.description
    self.backgroundImage = from.backgroundImage
    self.isPrivate = from.isPrivate
    self.calendar = from.calendar
    self.startingYear = from.startingYear
    self.months = from.months
    self.createdAt = from.createdAt
    self.updatedAt = from.updatedAt
    self.owner = try PublicUser(from: from.joined(User.self))
    self.members = try from.members.map(PublicMember.init(from:))
    self.inviteCode = from.inviteCode
  }
}

struct CreateCampaign: Content {
  var name: String
  var description: String?
  let backgroundImage: String
  let isPrivate: Bool
  let calendarId: Calendar.IDValue
  let startingYear: Int

  mutating func afterDecode() throws {
    if name.isEmpty {
      throw Abort(.badRequest, reason: "name must not be empty.")
    }

    if backgroundImage.isEmpty {
      throw Abort(.badRequest, reason: "backgroundImage must not be empty.")
    }
  }
}

extension Campaign {
  convenience init(from: CreateCampaign, calendar: Calendar, owner: User) throws {
    self.init()
    self.name = from.name
    self.description = from.description
    self.backgroundImage = from.backgroundImage
    self.isPrivate = from.isPrivate
    self.calendar = calendar.name
    self.startingYear = calendar.defaultStartingYear
    self.months = calendar.months
    try self.$owner.id = owner.requireID()
    self.inviteCode = UUID().uuidString
  }

  func userIsMember(userId: UUID?) -> Bool {
    guard let userId = userId else {
      return false
    }

    return self.members.contains { member in
      member.user.id == userId
    }
  }

  static func fetchCampaign(id: UUID, req: Request) async throws -> Campaign? {
    let token = req.auth.get(Token.self)

    let campaign = try await Campaign
      .query(on: req.db)
      .join(User.self, on: \Campaign.$owner.$id == \User.$id)
      .join(children: \.$members)
      .with(\.$members) {
        $0.with(\.$user)
      }
      .find(id)
      .get()

    guard let campaign = campaign else {
      return nil
    }

    if !campaign.userIsMember(userId: token?.userID) && campaign.isPrivate {
      return nil
    }

    return campaign
  }
}

Let’s start with the models before I add the view code. Unlike Django where the serializer for a model can simply exclude a few fields without having to redefine the whole model, in Vapor it’s par for the course to create these so-called “data transfer objects”; different versions of your database model for the public representation, the payload for creating a model, one for updating a model, and so on.

Then, in CreateCampaign you can see that had I to add my own validation logic to make sure that name and backgroundImage are not empty strings - logic that is not needed in Django since that information is already available in the model definition itself (blank=False, which is the default), and automatically validated.

Let’s move on to the view code.

struct CampaignController: RouteCollection {
  func boot(routes: RoutesBuilder) throws {
    routes.group("campaigns") { unprotectedRoutes in
      // Protected
      unprotectedRoutes.group(Token.guardMiddleware()) { protectedRoutes in
        protectedRoutes.get(use: list)
        protectedRoutes.post(use: create)

        protectedRoutes.group(":campaignID") { campaignRoutes in
          campaignRoutes.get(use: get)
          campaignRoutes.put(use: update)
          campaignRoutes.delete(use: delete)
        }
      }
    }
  }

  /// GET /api/campaigns
  func list(req: Request) async throws -> [PublicCampaign] {
    let token = try req.auth.require(Token.self)

    return try await Campaign
      .query(on: req.db)
      .join(User.self, on: \Campaign.$owner.$id == \User.$id)
      .join(children: \.$members)
      .filter(Member.self, \.$user.$id == token.userID)
      .with(\.$members) {
        $0.with(\.$user)
      }
      .all()
      .get()
      .map { try PublicCampaign.init(from: $0) }
  }
  
  /// GET /api/campaigns/:campaignID
  func get(req: Request) async throws -> PublicCampaign {
    let campaign = try await Campaign
      .fetchCampaign(id: req.parameters.require("campaignID"), req: req)
      .unwrap(or: Abort(.notFound, reason: "Campaign not found, or you don't have access to it"))

    return try PublicCampaign.init(from: campaign)
  }

  /// POST /api/campaigns
  func create(req: Request) async throws -> Response {
    let token = try req.auth.require(Token.self)
    let user = try await User.find(token.userID, on: req.db)
      .unwrap(or: Abort(.forbidden))
      .get()

    // Create campaign
    let createCampaign = try req.content.decode(CreateCampaign.self)

    let calendar = try await Calendar.find(createCampaign.calendarId, on: req.db)
    guard let calendar = calendar else {
      throw Abort(.notFound)
    }

    let campaign = try Campaign(from: createCampaign, calendar: calendar, owner: user)
    try await campaign.save(on: req.db)

    // Add yourself as a member (DM role)
    try await campaign.addMember(user: user, isDm: true, on: req.db)

    // Refetch the campaign so that all the relationships (like the members) are properly loaded
    return try await Campaign
      .fetchCampaign(id: campaign.requireID(), req: req)
      .map { try PublicCampaign.init(from: $0) }
      .encodeResponse(status: .created, for: req).get()
  }
}

The view code is pretty nice actually, it’s one of the best parts of working with Vapor. The fact that everything is strongly typed and checked by the compiler is honestly super useful and something I do miss when working with Django. If it compiles, I can be pretty sure it’ll work fine when calling these endpoints. There might be bugs in the logic of course, but it’s not like Django where you can write a whole bunch of code and you won’t know if it works until you call the endpoints and test every single branch of logic.

Another nice thing is that everything is very explicit, it’s easy to read the code from top to bottom and know exactly what a view does. It’s very easy to customize logic, since all the logic is your own. For example fetching a campaign and failing if you’re not a member and it’s not a publicly available campaign: very easy to do.

But boy, working with the data transfer objects and having to write all those really is a huge bummer.

Conclusion

I hate writing models, model migrations and the data transfer objects in Vapor - it’s so much boring repeated code to write! Validation needs to be witten by hand as well. But on the other hand, the view code is pretty nice to write. Yes, it’s a bit longer than the DRF version, but it’s understandable, fully customizable to exactly how I want it to work, and I know that if it compiles, that I won’t have weird crashes because some property wasn’t found on an object.

DRF on the other hand really excels in the models, automatic migrations and the serializers which are based on the models but really easily modified without having to redefine the entire model minus one field or something like that. The one controller that I showed above was also very readable and understandable. In reality most controllers for most of my apps’s features would be a lot simpler, making the difference with Vapor even bigger.

For example, here is the entire controller for the party loot feature. You can fetch the list of all loot, post a new one, get, update or delete an existing one:

class LootSerializer(serializers.ModelSerializer):
    class Meta:
        model = Loot
        exclude = ['author', 'campaign']

class LootController(viewsets.ModelViewSet):
    permission_classes = (CampaignMemberOrPublicReadOnlyPermission,)
    serializer_class = LootSerializer

    def get_queryset(self):
        return Loot.objects.filter(campaign_id=self.kwargs['campaign_id'])

    def perform_create(self, serializer):
        return serializer.save(author=self.request.user, campaign_id=self.kwargs['campaign_id'])

The equivalent Vapor code is something like this, and that doesn’t even include the DELETE endpoint!

struct CreateLoot: Content {
  var text: String
  var isHidden: Bool?

  mutating func afterDecode() throws {
    if text.isEmpty {
      throw Abort(.badRequest, reason: "text must not be empty.")
    }

    self.text = try clean(text)
  }
}

extension Loot {
  convenience init(from: CreateLoot, campaignId: Campaign.IDValue, authorId: User.IDValue) {
    self.init()
    self.text = from.text
    self.isHidden = from.isHidden ?? false
    self.$author.id = authorId
    self.$campaign.id = campaignId
  }
}

struct PublicLoot: Content {
  var id: UUID
  var text: String
  var createdAt: Date?
  var updatedAt: Date?

  init(from: Loot) throws {
    self.id = try from.requireID()
    self.text = from.text
    self.createdAt = from.createdAt
    self.updatedAt = from.updatedAt
  }
}

struct LootController: RouteCollection {
  func boot(routes: RoutesBuilder) throws {
    routes
      .grouped(Token.guardMiddleware(), CampaignMemberOrPublicReadOnlyAuthMiddleware())
      .group("campaigns", ":campaignID", "loot") { protectedRoutes in
        protectedRoutes.get(use: list)
        protectedRoutes.post(use: create)
        protectedRoutes.put(":lootID", use: update)
      }
  }

  /// GET /api/campaigns/:campaignID/loot
  func list(req: Request) async throws -> [PublicLoot] {
    return try await Loot
      .query(on: req.db)
      .filter(\.$campaign.$id == req.parameters.require("campaignID"))
      .sort(\.$updatedAt)
      .all()
      .map(PublicLoot.init(from:))
  }

  /// POST /api/campaigns/:campaignID/loot
  func create(req: Request) async throws -> PublicLoot {
    let token = try req.auth.require(Token.self)

    let createLoot = try req.content.decode(CreateLoot.self)
    let loot = try Loot(from: createLoot, campaignId: req.parameters.require("campaignID"), authorId: token.userID)
    try await loot.save(on: req.db)

    return try PublicLoot(from: loot)
  }

  /// PUT /api/campaigns/:campaignID/loot/:lootID
  func update(req: Request) async throws -> PublicLoot {
    let updateLoot = try req.content.decode(CreateLoot.self)

    let loot = try await Loot.findInCampaign(
      req.parameters.require("lootID"),
      campaignId: req.parameters.require("campaignID"),
      on: req.db
    )

    loot.text = updateLoot.text
    loot.isHidden = updateLoot.isHidden ?? false
    try await loot.save(on: req.db)

    return try PublicLoot(from: loot)
  }
}

So even though writing the controller in Vapor is kind of fun to do and extremely explicit and customizable.. there is just so much of it to write! It takes a bit longer to figure out how to do certain things with Django REST Framework (there are so many layers involved) but once you do, everything is super fast.

Not to mention simple developer quality-of-life things like Django automatically restarting the server on code changes, or not having to compile first, which can literally take four minutes with my tiny Vapor project. It’s a lot easier to get the Django server hosted somewhere, it should use less memory, has a much bigger community around it when you hit a dead-end, many more packages are available to use, the list goes on honestly.

All that is to say: I love you Vapor 4, your async/await support is really great, but I will build my backend with Django and Django REST Framework. Please let me know when writing models and dealing with the data transfer objects has gotten less boilerplate-y, and migrations are done automatically. I’ll definitely have another look then, but for now, I must say goodbye.