Skip to content

Instantly share code, notes, and snippets.

@maximkrouk
Last active September 13, 2020 21:12
Show Gist options
  • Save maximkrouk/7dccc660f917e634b3b6cfea006e5cee to your computer and use it in GitHub Desktop.
Save maximkrouk/7dccc660f917e634b3b6cfea006e5cee to your computer and use it in GitHub Desktop.
Generic CRUD Controller for Vapor
// Depends on `Builder`: https://gist.github.com/maximkrouk/eede7171952e044492c1fa57291bcf94
// Depends on Model.swift
import Vapor
import Fluent
enum GenericController<Model: APIModel>
where Model.IDValue: LosslessStringConvertible {
/// ID parameter key
@inlinable
static var idKey: String { "id" }
/// ID path component
@inlinable
static var idPath: PathComponent { .init(stringLiteral: ":\(idKey)") }
/// Schema path component
@inlinable
static var schemaPath: PathComponent { .init(stringLiteral: Model.schema) }
/// Extracts id parameter from a database
@inlinable
static func getID(_ req: Request) throws -> Model.IDValue {
guard let id = req.parameters.get(idKey, as: Model.IDValue.self) else {
throw Abort(.badRequest)
}
return id
}
/// Creates a new l managed objects in a database
@inlinable
static func _findByID(_ req: Request) throws -> EventLoopFuture<Model> {
Model.find(try getID(req), on: req.db).unwrap(or: Abort(.notFound))
}
/// Creates a new l managed objects in a database
@inlinable
static func _create(_ req: Request) throws -> EventLoopFuture<Model.Output> {
let request = try req.content.decode(Model.Input.self)
let model = try Model(request)
return model.save(on: req.db)
.flatMap { model.load(on: req.db) }
.unwrap(or: Abort(.notFound))
}
/// Reads all managed objects' output from a database
@inlinable
static func _readAll(_ req: Request) throws -> EventLoopFuture<Page<Model.Output>> {
Model.eagerLoadedQuery(on: req.db).paginate(for: req).map { $0.map(\.output) }
}
/// Reads specified managed object' output from a database
@inlinable
static func _readByID(_ req: Request) throws -> EventLoopFuture<Model.Output> {
Model.load(try getID(req), on: req.db).unwrap(or: Abort(.notFound))
}
/// Updates specified managed object in a database
@inlinable
static func _updateByID(_ req: Request) throws -> EventLoopFuture<Model.Output> {
let content = try req.content.decode(Model.Update.self)
return try _findByID(req)
.flatMapThrowing { model -> Model in
try modification(of: model) { try $0.update(content) }
}
.flatMap { $0.update(on: req.db).transform(to: $0) }
.flatMap { Model.load($0.id, on: req.db) }
.unwrap(or: Abort(.notFound))
}
/// Deletes managed object from a database
@inlinable
static func _deleteByID(_ req: Request) throws -> EventLoopFuture<HTTPStatus> {
try _findByID(req).flatMap { $0.delete(on: req.db) }.transform(to: .ok)
}
/// Makes initial routes setup
///
/// Uses default methods (without auth stuff) to setup routes:
/// ```
/// ┌––––––––––┬–––––––––––––┐
/// | GET | /schema |
/// ├––––––––––┼–––––––––––––┤
/// | POST | /schema |
/// ├––––––––––┼–––––––––––––┤
/// | GET | /schema/:id |
/// ├––––––––––┼–––––––––––––┤
/// | PUT | /schema/:id |
/// ├––––––––––┼–––––––––––––┤
/// | DELETE | /schema/:id |
/// └––––––––––┴–––––––––––––┘
/// ```
@inlinable @discardableResult
static func _setupRoutes(_ builder: RoutesBuilder) -> RoutesBuilder {
return Builder(builder.grouped(schemaPath))
.set { $0.on(.GET, use: _readAll) }
.set { $0.on(.POST, use: _create) }
.set { $0.grouped(idPath) }
.set { $0.on(.GET, use: _readByID) }
.set { $0.on(.PUT, use: _updateByID) }
.set { $0.on(.DELETE, use: _deleteByID) }
.build()
}
}
// MARK: - Secure routing
extension GenericController {
/// Protects the route
@inlinable
static func protected<Requirement: Authenticatable, Response>(
_ req: Request,
using auth: Requirement.Type,
handler: @escaping (Request) throws -> Response
) throws -> Response { try protected(using: auth, handler: handler)(req) }
/// Protects the route
@inlinable
static func protected<Requirement: Authenticatable, Response>(
_ req: Request,
using auth: Requirement.Type,
handler: @escaping (Request, Requirement) throws -> Response
) throws -> Response { try protected(using: auth, handler: handler)(req) }
/// Protects the route
@inlinable
static func protected<Requirement: Authenticatable, Response>(
using auth: Requirement.Type,
handler: @escaping (Request) throws -> Response
) -> (Request) throws -> Response {
protected(using: auth) { request, _ in try handler(request) }
}
/// Protects the route
@inlinable
static func protected<Requirement: Authenticatable, Response>(
using auth: Requirement.Type,
handler: @escaping (Request, Requirement) throws -> Response
) -> (Request) throws -> Response {
{ request in
return try handler(request, try request.auth.require(auth))
}
}
}
import Fluent
import Vapor
// MARK: - CRUDModel
protocol CRUDModel: Model {
associatedtype Input: Content
associatedtype Output: Content
associatedtype Update: Content
init(_ input: Input) throws
var output: Output { get }
func update(_ update: Update) throws
}
extension CRUDModel {
/// Used for updating specific values in `update` method
@inlinable
func update<Value>(_ keyPath: WritableKeyPath<Self, Value>, using optional: Value?) {
var _self = self
if let value = optional { _self[keyPath: keyPath] = value }
}
}
// MARK: - EagerLoadProvidingModel
protocol EagerLoadProvidingModel: Model {
/// Sets up query to load child properties
///
/// Does not modify query by default, should be implemented to load specific fields
///
/// Implementation example:
/// ```
/// static func eagerLoad(to builder: QueryBuilder<Self>) -> QueryBuilder<Self> {
/// builder.with(\.$field)
/// }
/// ```
///
/// Use conveniecne function for access this method
/// Usage example:
/// ```
/// eagerLoadedQuery(for: SomeAPIModel.self, on: req.db) // QueryBuilder<SomeAPIModel>
/// ```
static func eagerLoad(to builder: QueryBuilder<Self>) -> QueryBuilder<Self>
}
extension EagerLoadProvidingModel {
@inlinable
static func eagerLoad(to builder: QueryBuilder<Self>) -> QueryBuilder<Self> { builder }
}
// MARK: - APIModel
protocol APIModel: CRUDModel, EagerLoadProvidingModel {}
extension APIModel {
@inlinable
static func eagerLoadedQuery(on database: Database) -> QueryBuilder<Self> {
_eagerLoadedQuery(for: self, on: database)
}
/// Loads eager loaded instance from the database
///
/// Should not be reimplemented
@inlinable
func load(on database: Database) -> EventLoopFuture<Output?> {
Self.load(id, on: database)
}
/// Loads eager loaded instance from the database
///
/// Should not be reimplemented
@inlinable
static func load(_ id: IDValue?, on database: Database) -> EventLoopFuture<Output?> {
_load(self, id, on: database)
}
}
private func _eagerLoadedQuery<Model: APIModel>(for type: Model.Type, on database: Database) -> QueryBuilder<Model> {
type.eagerLoad(to: database.query(type))
}
private func _load<Model: APIModel>(_ type: Model.Type, _ id: Model.IDValue?, on database: Database)
-> EventLoopFuture<Model.Output?> {
guard let id = id else { return database.eventLoop.makeSucceededFuture(nil) }
return _eagerLoadedQuery(for: type, on: database)
.filter(\._$id == id).first()
.map { $0?.output }
}
@maximkrouk
Copy link
Author

maximkrouk commented May 19, 2020

Usage

I prefer to implement custom logic using methods without _ prefix

User controller example

import Vapor

extension GenericController where Model == UserModel {
    
    @discardableResult
    static func setupRoutes(_ builder: RoutesBuilder) -> RoutesBuilder {
        return Builder(builder.grouped(schemaPath))
            .set { $0.post(use: create) }
            .set { $0.post("login", use: login) }
            .build()
    }
    
    static func create(_ req: Request) throws -> EventLoopFuture<Model.LoginResponse> {
        let input = try req.content.decode(UserModel.Input.self)
        return try UserModel(input)
            .save(on: req.db)
            .transform(to: try login(req))
    }
    
    static func login(_ req: Request) throws -> EventLoopFuture<Model.LoginResponse> {
        let input = try req.content.decode(UserModel.Input.self)
        return UserModel.eagerLoadedQuery(on: req.db)
            .filter(\.$username, .equal, input.username).first()
            .unwrap(or: Abort(.notFound))
            .guard({ (try? Bcrypt.verify(input.password, created: $0.passwordHash)) == true },
                   else: Abort(.init(statusCode: 400, reasonPhrase: "Incorrect password")))
            .flatMapThrowing {
                Model.LoginResponse(token: try req.jwt.sign(
                    UserModel.JWTPayload(
                        sub: .init(value: $0.id!.uuidString),
                        username: $0.username,
                        exp: .init(value: Date().addingTimeInterval(3600))
                    )
                )
            )
        }
    }
    
}

Todos controller example

extension GenericController where Model == TodoModel {
    
    @discardableResult
    static func setupRoutes(_ builder: RoutesBuilder) -> RoutesBuilder {
        return Builder(builder.grouped(schemaPath))
            .set { $0.get(use: readAll) }
            .set { $0.post(use: create) }
            .set { $0.grouped(idPath) }
            .set { $0.on(.DELETE, use: protected(using: UserModel.JWTPayload.self, handler: _deleteByID)) }
            .build()
    }
    
    static func create(_ req: Request) throws -> EventLoopFuture<TodoModel.Output> {
        let payload = try req.auth.require(UserModel.JWTPayload.self)
        let input = try req.content.decode(TodoModel.Input.self)
        let model = try TodoModel(input)
        
        model.$user.id = payload.userID
        return model.save(on: req.db)
            .flatMap { model.load(on: req.db) }
            .unwrap(or: Abort(.notFound))
    }
    
    static func readAll(_ req: Request) throws -> EventLoopFuture<[TodoModel.Output]> {
        let payload = try req.auth.require(UserModel.JWTPayload.self)
        return Model.eagerLoadedQuery(on: req.db)
            .filter(\.$user.$id, .equal, payload.userID).all()
            .map { $0.map(\.output) }
    }
    
}

JWT-protected routes setup

func routes(_ app: Application) throws {
    let protected = app.grouped(JWTUserModelBearerAuthenticator())
    GenericController<TodoModel>.setupRoutes(protected)
    GenericController<UserModel>.setupRoutes(protected)
}

Back to index

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment