Search code examples
swiftrestvaporvapor-fluent

Vapor 3 - How to check for similar email before saving object


I would like to create a route to let users update their data (e.g. changing their email or their username). To make sure a user cannot use the same username as another user, I would like to check if a user with the same username already exists in the database.

I have already made the username unique in the migrations.

I have a user model that looks like this:

struct User: Content, SQLiteModel, Migration {
    var id: Int?
    var username: String
    var name: String
    var email: String
    var password: String

    var creationDate: Date?

    // Permissions
    var staff: Bool = false
    var superuser: Bool = false

    init(username: String, name: String, email: String, password: String) {
        self.username = username
        self.name = name
        self.email = email
        self.password = password
        self.creationDate = Date()
    }
}

This is the piece of code where I want to use it:

func create(_ req: Request) throws -> EventLoopFuture<User> {
    return try req.content.decode(UserCreationRequest.self).flatMap { userRequest in

        // Check if `userRequest.email` already exists
        // If if does -> throw Abort(.badRequest, reason: "Email already in use")
        // Else -> Go on with creation

        let digest = try req.make(BCryptDigest.self)
        let hashedPassword = try digest.hash(userRequest.password)
        let persistedUser = User(name: userRequest.name, email: userRequest.email, password: hashedPassword)

        return persistedUser.save(on: req)
    }
}

I could do it like this (see next snippet) but it seems a strange option as it requires a lot of nesting when more checks for e.g. uniqueness would have to be performed (for instance in the case of updating a user).

func create(_ req: Request) throws -> EventLoopFuture<User> {
    return try req.content.decode(UserCreationRequest.self).flatMap { userRequest in
        let userID = userRequest.email
        return User.query(on: req).filter(\.userID == userID).first().flatMap { existingUser in
            guard existingUser == nil else {
                throw Abort(.badRequest, reason: "A user with this email already exists")
            }

            let digest = try req.make(BCryptDigest.self)
            let hashedPassword = try digest.hash(userRequest.password)
            let persistedUser = User(name: userRequest.name, email: userRequest.email, password: hashedPassword)

            return persistedUser.save(on: req)
        }
    }
}

As one of the answers suggested I've tried to add Error middleware (see next snippet) but this does not correctly catch the error (maybe I am doing something wrong in the code - just started with Vapor).

import Vapor
import FluentSQLite

enum InternalError: Error {
    case emailDuplicate
}

struct EmailDuplicateErrorMiddleware: Middleware {
    func respond(to request: Request, chainingTo next: Responder) throws -> EventLoopFuture<Response> {
        let response: Future<Response>

        do {
            response = try next.respond(to: request)
        } catch is SQLiteError {
            response = request.eventLoop.newFailedFuture(error: InternalError.emailDuplicate)
        }

        return response.catchFlatMap { error in
            if let response = error as? ResponseEncodable {
                do {
                    return try response.encode(for: request)
                } catch {
                    return request.eventLoop.newFailedFuture(error: InternalError.emailDuplicate)
                }
            } else {
                return request.eventLoop.newFailedFuture(error: error)
            }
        }
    }
}

Solution

  • I would make the field unique in the model using a Migration such as:

    extension User: Migration {
      static func prepare(on connection: SQLiteConnection) -> Future<Void> {
        return Database.create(self, on: connection) { builder in
          try addProperties(to: builder)
          builder.unique(on: \.email)
        }
      }
    }
    

    If you use a default String as the field type for email, then you will need to reduce it as this creates a field VARCHAR(255) which is too big for a UNIQUE key. I would then use a bit of custom Middleware to trap the error that arises when a second attempt to save a record is made using the same email.

    struct DupEmailErrorMiddleware: Middleware
    {
        func respond(to request: Request, chainingTo next: Responder) throws -> EventLoopFuture<Response>
        {
            let response: Future<Response>
            do {
                response = try next.respond(to: request)
            } catch is MySQLError {
                // needs a bit more sophistication to check the specific error
                response = request.eventLoop.newFailedFuture(error: InternalError.dupEmail)
            }
            return response.catchFlatMap
            {
                error in
                if let response = error as? ResponseEncodable
                {
                    do
                    {
                        return try response.encode(for: request)
                    }
                    catch
                    {
                        return request.eventLoop.newFailedFuture(error: InternalError.dupEmail)
                    }
                } else
                {
                    return request.eventLoop.newFailedFuture(error: error   )
                }
            }
        }
    }
    

    EDIT:

    Your custom error needs to be something like:

    enum InternalError: Debuggable, ResponseEncodable
    {
        func encode(for request: Request) throws -> EventLoopFuture<Response>
        {
            let response = request.response()
            let eventController = EventController()
            //TODO make this return to correct view
            eventController.message = reason
            return try eventController.index(request).map
            {
                html in
                try response.content.encode(html)
                return response
            }
        }
    
        case dupEmail
    
        var identifier:String
        {
            switch self
            {
                case .dupEmail: return "dupEmail"
            }
        }
    
        var reason:String
        {
           switch self
           {
                case .dupEmail: return "Email address already used"
            }
        }
    }
    

    In the code above, the actual error is displayed to the user by setting a value in the controller, which is then picked up in the view and an alert displayed. This method allows a general-purpose error handler to take care of displaying the error messages. However, in your case, it might be that you could just create the response in the catchFlatMap.