You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Vapor中复用认证路由逻辑以同时支持API与Web前端?

问题描述

我正在用Vapor搭建认证系统,已经实现了AuthenticationController作为RouteCollection,代码如下:

struct AuthenticationController: RouteCollection {

    func boot(routes: RoutesBuilder) throws {
        routes.group("auth") { auth in
            auth.post("register", use: register)
            auth.post("login", use: login)
        }
        routes.group("email-verification") { emailVerificationRoutes in
            emailVerificationRoutes.post("", use: sendEmailVerification)
            emailVerificationRoutes.get("", use: verifyEmail)
        }
        ...
    }

    private func register(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
        ...
    }

    private func login(_ req: Request) throws -> EventLoopFuture<LoginResponse> {
        ...
    }

    ...

}

该路由集合挂载在/api路径下:

app.group("api") { api in
    try! api.register(collection: AuthenticationController())
}

这些API接口返回EventLoopFuture类型,无数据时返回HTTPStatus,需要返回数据时返回实现Content协议的JSON结构体。

现在希望重构认证逻辑,同时为Web前端提供HTML界面(比如/auth/login路径的表单页面、错误响应等),要求Web界面和/api/auth/login等API接口共用核心逻辑。考虑过用flatMap转换EventLoopFuture返回View,或者抽离逻辑到共享层、创建WebAuthenticationController,但没找到示例,询问合适的实现方式。

解决方案

核心思路是将业务逻辑与路由/响应处理解耦:把认证的核心逻辑抽离到独立的服务类,让API控制器和Web控制器各自处理响应格式,同时复用同一套业务逻辑。

步骤1:抽离核心认证逻辑到服务类

创建AuthenticationService,封装所有核心认证操作,返回业务层面的结果(而非直接返回HTTP响应或View),这样可以被不同的控制器复用。

// 自定义业务错误,用于在服务层传递错误信息
enum AuthenticationError: Error {
    case invalidCredentials
    case emailAlreadyRegistered
    case verificationTokenInvalidOrExpired
    // 其他业务错误...
}

struct AuthenticationService {
    func register(req: Request, userData: RegisterData) -> EventLoopFuture<User> {
        // 核心注册逻辑:检查邮箱是否已存在、创建用户、保存到数据库等
        return User.query(on: req.db)
            .filter(\.$email == userData.email)
            .first()
            .flatMap { existingUser in
                guard existingUser == nil else {
                    return req.eventLoop.makeFailedFuture(AuthenticationError.emailAlreadyRegistered)
                }
                let user = User(email: userData.email, passwordHash: try! Bcrypt.hash(userData.password))
                return user.save(on: req.db).map { user }
            }
    }

    func login(req: Request, loginData: LoginData) -> EventLoopFuture<User> {
        // 核心登录逻辑:验证用户名密码、返回用户模型
        return User.query(on: req.db)
            .filter(\.$email == loginData.email)
            .first()
            .flatMap { user in
                guard let user = user, try! Bcrypt.verify(loginData.password, created: user.passwordHash) else {
                    return req.eventLoop.makeFailedFuture(AuthenticationError.invalidCredentials)
                }
                return req.eventLoop.makeSucceededFuture(user)
            }
    }

    // 邮箱验证等其他核心方法...
}

// 为Request扩展,方便获取服务实例(可选,也可以直接初始化)
extension Request {
    var authService: AuthenticationService {
        .init()
    }
}

步骤2:改造原有API控制器

让AuthenticationController调用服务类的方法,然后将业务结果转换成API需要的响应格式:

struct AuthenticationController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        routes.group("auth") { auth in
            auth.post("register", use: register)
            auth.post("login", use: login)
        }
        routes.group("email-verification") { emailVerificationRoutes in
            emailVerificationRoutes.post("", use: sendEmailVerification)
            emailVerificationRoutes.get("", use: verifyEmail)
        }
    }

    private func register(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
        let registerData = try req.content.decode(RegisterData.self)
        return req.authService.register(req: req, userData: registerData)
            .map { _ in .created } // 注册成功返回201
            .flatMapError { error in
                // 根据业务错误返回对应的HTTP状态码和错误信息
                switch error {
                case AuthenticationError.emailAlreadyRegistered:
                    return req.eventLoop.makeFailedFuture(Abort(.conflict, reason: "邮箱已被注册"))
                default:
                    return req.eventLoop.makeFailedFuture(Abort(.internalServerError))
                }
            }
    }

    private func login(_ req: Request) throws -> EventLoopFuture<LoginResponse> {
        let loginData = try req.content.decode(LoginData.self)
        return req.authService.login(req: req, loginData: loginData)
            .map { user in
                // 生成JWT或其他登录凭证,封装成LoginResponse
                LoginResponse(token: try! user.generateToken(), userID: user.id!)
            }
            .flatMapError { error in
                switch error {
                case AuthenticationError.invalidCredentials:
                    return req.eventLoop.makeFailedFuture(Abort(.unauthorized, reason: "用户名或密码错误"))
                default:
                    return req.eventLoop.makeFailedFuture(Abort(.internalServerError))
                }
            }
    }

    // 其他API方法...
}

步骤3:创建Web界面控制器

新建WebAuthenticationController,处理HTML页面的请求,调用同一个服务类,根据结果渲染对应的View:

struct WebAuthenticationController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        routes.group("auth") { auth in
            // 显示登录表单
            auth.get("login", use: showLoginForm)
            // 处理登录表单提交
            auth.post("login", use: handleLoginSubmit)
            // 显示注册表单
            auth.get("register", use: showRegisterForm)
            // 处理注册表单提交
            auth.post("register", use: handleRegisterSubmit)
            // 邮箱验证相关路由...
        }
    }

    private func showLoginForm(_ req: Request) throws -> EventLoopFuture<View> {
        // 渲染登录表单页面,可传递错误信息(如果有的话)
        let context = LoginContext(errorMessage: req.query[String.self, at: "error"])
        return req.view.render("auth/login", context)
    }

    private func handleLoginSubmit(_ req: Request) throws -> EventLoopFuture<Response> {
        let loginData = try req.content.decode(LoginData.self)
        return req.authService.login(req: req, loginData: loginData)
            .flatMap { user in
                // 登录成功,设置会话(如果用会话认证)
                req.session.authenticate(user)
                // 重定向到首页或用户中心
                return req.eventLoop.makeSucceededFuture(req.redirect(to: "/dashboard"))
            }
            .flatMapError { error in
                // 登录失败,重定向回登录页面并携带错误信息
                let errorMessage: String
                switch error {
                case AuthenticationError.invalidCredentials:
                    errorMessage = "用户名或密码错误"
                default:
                    errorMessage = "登录失败,请稍后重试"
                }
                return req.eventLoop.makeSucceededFuture(req.redirect(to: "/auth/login?error=\(errorMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)"))
            }
    }

    private func showRegisterForm(_ req: Request) throws -> EventLoopFuture<View> {
        let context = RegisterContext(errorMessage: req.query[String.self, at: "error"])
        return req.view.render("auth/register", context)
    }

    private func handleRegisterSubmit(_ req: Request) throws -> EventLoopFuture<Response> {
        let registerData = try req.content.decode(RegisterData.self)
        return req.authService.register(req: req, userData: registerData)
            .flatMap { user in
                // 注册成功,自动登录或重定向到登录页
                req.session.authenticate(user)
                return req.eventLoop.makeSucceededFuture(req.redirect(to: "/dashboard"))
            }
            .flatMapError { error in
                let errorMessage: String
                switch error {
                case AuthenticationError.emailAlreadyRegistered:
                    errorMessage = "邮箱已被注册"
                default:
                    errorMessage = "注册失败,请稍后重试"
                }
                return req.eventLoop.makeSucceededFuture(req.redirect(to: "/auth/register?error=\(errorMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)"))
            }
    }

    // 其他Web相关方法...
}

// 页面渲染所需的上下文结构体
struct LoginContext: Encodable {
    let errorMessage: String?
}

struct RegisterContext: Encodable {
    let errorMessage: String?
}

步骤4:挂载路由

分别挂载API控制器和Web控制器:

// 挂载API路由
app.group("api") { api in
    try api.register(collection: AuthenticationController())
}

// 挂载Web路由
try app.register(collection: WebAuthenticationController())

关键说明

  • 解耦逻辑:服务层专注于业务逻辑,控制器专注于响应格式处理(API返回JSON,Web返回HTML/重定向),符合单一职责原则。
  • 复用核心逻辑:API和Web控制器调用同一个服务类方法,避免代码重复。
  • 错误处理:服务层抛出业务错误,控制器根据自身场景转换为对应的响应(API返回带错误信息的JSON,Web重定向并携带错误参数)。
  • EventLoopFuture转换:在Web控制器中,通过flatMap和flatMapError处理服务返回的EventLoopFuture,将结果转换为View或Response(重定向)。

内容的提问来源于stack exchange,提问作者GP89

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.18 06:55:21