feat: Begins breaking out database interfaces and api controllers into seperate items.

This commit is contained in:
2025-01-10 13:31:56 -05:00
parent 3f30327fd8
commit 88ee71cb68
14 changed files with 624 additions and 36 deletions

View File

@@ -9,3 +9,8 @@ included:
- Tests
ignore_multiline_statement_conditions: true
identifier_name:
excluded:
- "db"
- "id"

View File

@@ -0,0 +1,64 @@
import Fluent
import Vapor
struct EmployeeApiController: RouteCollection {
private let employeeDB = EmployeeDB()
func boot(routes: any RoutesBuilder) throws {
let protected = routes.apiProtected(route: "employees")
protected.get(use: index(req:))
protected.post(use: create(req:))
protected.group(":employeeID") {
$0.get(use: get(req:))
$0.put(use: update(req:))
$0.delete(use: delete(req:))
}
}
@Sendable
func index(req: Request) async throws -> [Employee.DTO] {
let params = try req.query.decode(EmployeesIndexQuery.self)
return try await employeeDB.fetchAll(active: params.active, on: req.db)
}
@Sendable
func create(req: Request) async throws -> Employee.DTO {
try Employee.Create.validate(content: req)
let create = try req.content.decode(Employee.Create.self)
return try await employeeDB.create(create, on: req.db)
}
@Sendable
func get(req: Request) async throws -> Employee.DTO {
guard let id = req.parameters.get("employeeID", as: Employee.IDValue.self),
let employee = try await employeeDB.get(id: id, on: req.db)
else {
throw Abort(.notFound)
}
return employee
}
@Sendable
func update(req: Request) async throws -> Employee.DTO {
try Employee.Update.validate(content: req)
guard let employeeID = req.parameters.get("employeeID", as: Employee.IDValue.self) else {
throw Abort(.badRequest, reason: "Employee id value not provided")
}
let updates = try req.content.decode(Employee.Update.self)
return try await employeeDB.update(id: employeeID, with: updates, on: req.db)
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let employeeID = req.parameters.get("employeeID", as: Employee.IDValue.self) else {
throw Abort(.badRequest, reason: "Employee id value not provided")
}
try await employeeDB.delete(id: employeeID, on: req.db)
return .ok
}
}
struct EmployeesIndexQuery: Content {
let active: Bool?
}

View File

@@ -0,0 +1,62 @@
import Fluent
import Vapor
// TODO: Add update route.
struct PurchaseOrderApiController: RouteCollection {
private let purchaseOrders = PurchaseOrderDB()
func boot(routes: any RoutesBuilder) throws {
let protected = routes.apiProtected(route: "purchase-orders")
protected.get(use: index(req:))
protected.post(use: create(req:))
protected.group(":id") {
$0.get(use: get(req:))
$0.delete(use: delete(req:))
}
}
@Sendable
func index(req: Request) async throws -> [PurchaseOrder.DTO] {
try await purchaseOrders.fetchAll(on: req.db)
}
@Sendable
func create(req: Request) async throws -> PurchaseOrder.DTO {
try PurchaseOrder.Create.validate(content: req)
let model = try req.content.decode(PurchaseOrder.Create.self)
return try await purchaseOrders.create(
model,
createdById: req.auth.require(User.self).requireID(),
on: req.db
)
}
@Sendable
func get(req: Request) async throws -> PurchaseOrder.DTO {
guard let id = req.parameters.get("id", as: PurchaseOrder.IDValue.self),
let purchaseOrder = try await purchaseOrders.get(id: id, on: req.db)
else {
throw Abort(.notFound)
}
return purchaseOrder
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let id = req.parameters.get("id", as: PurchaseOrder.IDValue.self) else {
throw Abort(.badRequest, reason: "Purchase order id not provided.")
}
try await purchaseOrders.delete(id: id, on: req.db)
return .ok
}
// @Sendable
// func update(req: Request) async throws -> PurchaseOrder.DTO {
// guard let id = req.parameters.get("id", as: PurchaseOrder.IDValue.self) else {
// throw Abort(.badRequest, reason: "Purchase order id not provided.")
// }
// try await purchaseOrders.delete(id: id, on: req.db)
// return .ok
// }
}

View File

@@ -0,0 +1,59 @@
import Fluent
import Vapor
// TODO: Add update and get by id.
struct UserApiController: RouteCollection {
let users = UserDB()
func boot(routes: any RoutesBuilder) throws {
let unProtected = routes.apiUnprotected(route: "users")
let protected = routes.apiProtected(route: "users")
unProtected.post(use: create(req:))
protected.get(use: index(req:))
protected.post("login", use: login(req:))
protected.group(":id") {
$0.delete(use: delete(req:))
}
}
@Sendable
func index(req: Request) async throws -> [User.DTO] {
try await users.fetchAll(on: req.db)
}
@Sendable
func create(req: Request) async throws -> User.DTO {
// Allow the first user to be created without authentication.
let count = try await User.query(on: req.db).count()
if count > 0 {
guard req.auth.get(User.self) != nil else {
throw Abort(.unauthorized)
}
}
try User.Create.validate(content: req)
let model = try req.content.decode(User.Create.self)
return try await users.create(model, on: req.db)
}
@Sendable
func login(req: Request) async throws -> UserToken {
let user = try req.auth.require(User.self)
return try await users.login(user: user, on: req.db)
}
// @Sendable
// func get(req: Request) async throws -> User.DTO {
// guard let id = req.parameters.get("id", as: User.IDValue.self),
// let user = users.
// }
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let id = req.parameters.get("id", as: User.IDValue.self) else {
throw Abort(.badRequest, reason: "User id not provided")
}
try await users.delete(id: id, on: req.db)
return .ok
}
}

View File

@@ -0,0 +1,52 @@
import Fluent
import Vapor
struct VendorApiController: RouteCollection {
private let vendors = VendorDB()
func boot(routes: any RoutesBuilder) throws {
let protected = routes.apiProtected(route: "vendors")
protected.get(use: index(req:))
protected.post(use: create(req:))
protected.group(":id") {
$0.put(use: update(req:))
$0.delete(use: delete(req:))
}
}
@Sendable
func index(req: Request) async throws -> [Vendor.DTO] {
let params = try req.query.decode(VendorsIndexQuery.self)
return try await vendors.fetchAll(withBranches: params.branches, on: req.db)
}
@Sendable
func create(req: Request) async throws -> Vendor.DTO {
try Vendor.Create.validate(content: req)
let model = try req.content.decode(Vendor.Create.self)
return try await vendors.create(model, on: req.db)
}
@Sendable
func update(req: Request) async throws -> Vendor.DTO {
guard let id = req.parameters.get("id", as: Vendor.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor id not provided.")
}
try Vendor.Update.validate(content: req)
let updates = try req.content.decode(Vendor.Update.self)
return try await vendors.update(id: id, with: updates, on: req.db)
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let id = req.parameters.get("id", as: Vendor.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor id not provided.")
}
try await vendors.delete(id: id, on: req.db)
return .ok
}
}
struct VendorsIndexQuery: Content {
let branches: Bool?
}

View File

@@ -0,0 +1,64 @@
import Fluent
import Vapor
struct VendorBranchApiController: RouteCollection {
private let vendorBranches = VendorBranchDB()
func boot(routes: any RoutesBuilder) throws {
let prefix = routes.apiProtected(route: "vendors")
let root = prefix.grouped("branches")
root.get(use: index(req:))
root.group(":id") {
$0.put(use: update(req:))
$0.delete(use: delete(req:))
}
prefix.group(":vendorID", "branches") {
$0.get(use: indexForVendor(req:))
$0.post(use: create(req:))
}
}
@Sendable
func index(req: Request) async throws -> [VendorBranch.DTO] {
try await vendorBranches.fetchAll(on: req.db)
}
@Sendable
func indexForVendor(req: Request) async throws -> [VendorBranch.DTO] {
guard let id = req.parameters.get("vendorID", as: Vendor.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor id not provided.")
}
return try await vendorBranches.fetch(for: id, on: req.db)
}
@Sendable
func create(req: Request) async throws -> VendorBranch.DTO {
guard let id = req.parameters.get("vendorID", as: Vendor.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor id not provided.")
}
try VendorBranch.Create.validate(content: req)
let model = try req.content.decode(VendorBranch.Create.self)
return try await vendorBranches.create(model, for: id, on: req.db)
}
@Sendable
func update(req: Request) async throws -> VendorBranch.DTO {
guard let id = req.parameters.get("id", as: VendorBranch.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor branch id not provided.")
}
try VendorBranch.Update.validate(content: req)
let updates = try req.content.decode(VendorBranch.Update.self)
return try await vendorBranches.update(id: id, with: updates, on: req.db)
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
guard let id = req.parameters.get("id", as: VendorBranch.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor branch id not provided.")
}
try await vendorBranches.delete(id: id, on: req.db)
return .ok
}
}

View File

@@ -1,6 +1,7 @@
import Fluent
import Vapor
// TODO: Use DB controllers.
struct ApiController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let api = routes.grouped("api", "v1")
@@ -29,6 +30,9 @@ struct ApiController: RouteCollection {
purchaseOrders.get(use: purchaseOrdersIndex(req:))
purchaseOrders.post(use: createPurchaseOrder(req:))
purchaseOrders.group(":purchaseOrderID") {
$0.get(use: getPurchaseOrder(req:))
}
users.get(use: usersIndex(req:))
api.post("users", use: createUser(req:))
@@ -102,8 +106,6 @@ struct ApiController: RouteCollection {
// MARK: - PurchaseOrders
// TODO: Add fetch by id.
// TODO: Add pagination and filters.
@Sendable
func purchaseOrdersIndex(req: Request) async throws -> [PurchaseOrder.DTO] {
@@ -118,6 +120,27 @@ struct ApiController: RouteCollection {
.map { $0.toDTO() }
}
@Sendable
func getPurchaseOrder(req: Request) async throws -> PurchaseOrder.DTO {
guard let id = req.parameters.get("purchaseOrderID", as: Int.self) else {
throw Abort(.badRequest)
}
guard let purchaseOrder = try await PurchaseOrder.query(on: req.db)
.filter(\.$id == id)
.with(\.$createdBy)
.with(\.$createdFor)
.with(\.$vendorBranch, {
$0.with(\.$vendor)
})
.first()
else {
throw Abort(.notFound)
}
return purchaseOrder.toDTO()
}
@Sendable
func createPurchaseOrder(req: Request) async throws -> PurchaseOrder.DTO {
try PurchaseOrder.Create.validate(content: req)
@@ -287,11 +310,3 @@ struct ApiController: RouteCollection {
return branch.toDTO()
}
}
struct VendorsIndexQuery: Content {
let branches: Bool?
}
struct EmployeesIndexQuery: Content {
let active: Bool?
}

View File

@@ -3,6 +3,7 @@ import Vapor
struct PurchaseOrderViewController: RouteCollection {
private let api = ApiController()
private let api2 = PurchaseOrderDB()
func boot(routes: any RoutesBuilder) throws {
let pos = routes.protected.grouped("purchase-orders")
@@ -11,9 +12,10 @@ struct PurchaseOrderViewController: RouteCollection {
pos.post(use: create(req:))
}
// TODO: Use pageinated version.
@Sendable
func index(req: Request) async throws -> View {
let purchaseOrders = try await api.purchaseOrdersIndex(req: req)
let purchaseOrders = try await api2.fetchAll(on: req.db)
let branches = try await api.getBranches(req: req)
let employees = try await api.employeesIndex(req: req)
req.logger.debug("Branches: \(branches)")
@@ -30,31 +32,9 @@ struct PurchaseOrderViewController: RouteCollection {
func create(req: Request) async throws -> View {
try PurchaseOrder.FormCreate.validate(content: req)
let createdById = try req.auth.require(User.self).requireID()
let create = try req.content.decode(PurchaseOrder.FormCreate.self)
guard let employee = try await Employee.find(create.createdForID, on: req.db) else {
throw Abort(.notFound, reason: "Employee not found.")
}
guard employee.active else {
throw Abort(.badRequest, reason: "Employee is not active, unable to generate a PO for in-active employees")
}
let purchaseOrder = create.toModel(createdByID: createdById)
try await purchaseOrder.save(on: req.db)
let loaded = try await PurchaseOrder.query(on: req.db)
.filter(\.$id == purchaseOrder.requireID())
.with(\.$createdFor)
.with(\.$createdBy)
.with(\.$vendorBranch) {
$0.with(\.$vendor)
}
.first()
return try await req.view.render("purchaseOrders/table-row", loaded)
// let purchaseOrders = try await api.purchaseOrdersIndex(req: req)
// return try await req.view.render("purchaseOrders/table", ["purchaseOrders": purchaseOrders])
let create = try req.content.decode(PurchaseOrder.FormCreate.self).toCreate()
let purchaseOrder = try await api2.create(create, createdById: createdById, on: req.db)
return try await req.view.render("purchaseOrders/table-row", purchaseOrder)
}
}
@@ -113,6 +93,7 @@ private extension PurchaseOrder {
let createdForID: Employee.IDValue
let vendorBranchID: VendorBranch.IDValue
// TODO: Remove.
func toModel(createdByID: User.IDValue) -> PurchaseOrder {
.init(
id: id,
@@ -127,6 +108,18 @@ private extension PurchaseOrder {
updatedAt: nil
)
}
func toCreate() -> PurchaseOrder.Create {
.init(
id: id,
workOrder: workOrder != nil ? (workOrder == "" ? nil : Int(workOrder!)) : nil,
materials: materials,
customer: customer,
truckStock: truckStock,
createdForID: createdForID,
vendorBranchID: vendorBranchID
)
}
}
}

View File

@@ -0,0 +1,49 @@
import Fluent
import Vapor
// An intermediate layer between our api and view controllers that interacts with the
// database model.
struct EmployeeDB {
func create(_ model: Employee.Create, on db: any Database) async throws -> Employee.DTO {
let model = model.toModel()
try await model.save(on: db)
return model.toDTO()
}
func fetchAll(active: Bool? = nil, on db: any Database) async throws -> [Employee.DTO] {
var query = Employee.query(on: db)
.sort(\.$lastName)
if let active {
query = query.filter(\.$active == active)
}
return try await query.all().map { $0.toDTO() }
}
func get(id: Employee.IDValue, on db: any Database) async throws -> Employee.DTO? {
try await Employee.find(id, on: db).map { $0.toDTO() }
}
func update(
id: Employee.IDValue,
with updates: Employee.Update,
on db: any Database
) async throws -> Employee.DTO {
guard let employee = try await Employee.find(id, on: db) else {
throw Abort(.badRequest, reason: "Employee id not found.")
}
employee.applyUpdates(updates)
try await employee.save(on: db)
return employee.toDTO()
}
func delete(id: Employee.IDValue, on db: any Database) async throws {
guard let employee = try await Employee.find(id, on: db) else {
throw Abort(.badRequest, reason: "Employee id not found.")
}
try await employee.delete(on: db)
}
}

View File

@@ -0,0 +1,65 @@
import Fluent
import Vapor
// An intermediate between our api and view controllers that interacts with the
// database.
struct PurchaseOrderDB {
func create(
_ model: PurchaseOrder.Create,
createdById: User.IDValue,
on db: any Database
) async throws -> PurchaseOrder.DTO {
guard let employee = try await Employee.find(model.createdForID, on: db) else {
throw Abort(.notFound, reason: "Employee not found.")
}
guard employee.active else {
throw Abort(.badRequest, reason: "Employee is not active, unable to generate a PO for in-active employees")
}
let purchaseOrder = model.toModel(createdByID: createdById)
try await purchaseOrder.save(on: db)
guard let loaded = try await get(id: purchaseOrder.requireID(), on: db) else {
return purchaseOrder.toDTO()
}
return loaded
}
func fetchAll(on db: any Database) async throws -> [PurchaseOrder.DTO] {
try await PurchaseOrder.allQuery(on: db)
.sort(\.$id, .descending)
.all().map { $0.toDTO() }
}
func fetchPage(_ page: Int, limit: Int, on db: any Database) async throws -> Page<PurchaseOrder.DTO> {
try await PurchaseOrder.allQuery(on: db)
.sort(\.$id, .descending)
.paginate(PageRequest(page: page, per: limit))
.map { $0.toDTO() }
}
func get(id: PurchaseOrder.IDValue, on db: any Database) async throws -> PurchaseOrder.DTO? {
try await PurchaseOrder.allQuery(on: db)
.filter(\.$id == id)
.first()?.toDTO()
}
func delete(id: PurchaseOrder.IDValue, on db: any Database) async throws {
guard let purchaseOrder = try await PurchaseOrder.find(id, on: db) else {
throw Abort(.notFound)
}
try await purchaseOrder.delete(on: db)
}
}
private extension PurchaseOrder {
static func allQuery(on db: any Database) -> QueryBuilder<PurchaseOrder> {
PurchaseOrder.query(on: db)
.with(\.$createdBy)
.with(\.$createdFor)
.with(\.$vendorBranch) { branch in
branch.with(\.$vendor)
}
}
}

View File

@@ -0,0 +1,36 @@
import Fluent
import Vapor
struct UserDB {
func create(_ model: User.Create, on db: any Database) async throws -> User.DTO {
guard model.password == model.confirmPassword else {
throw Abort(.badRequest, reason: "Passwords did not match.")
}
let user = try User(
username: model.username,
email: model.email,
passwordHash: Bcrypt.hash(model.password)
)
try await user.save(on: db)
return user.toDTO()
}
func login(user: User, on db: any Database) async throws -> UserToken {
let token = try user.generateToken()
try await token.save(on: db)
return token
}
func fetchAll(on db: any Database) async throws -> [User.DTO] {
try await User.query(on: db).all().map { $0.toDTO() }
}
func delete(id: User.IDValue, on db: any Database) async throws {
guard let user = try await User.find(id, on: db) else {
throw Abort(.notFound)
}
try await user.delete(on: db)
}
}

View File

@@ -0,0 +1,62 @@
import Fluent
import Vapor
struct VendorBranchDB {
func create(
_ model: VendorBranch.Create,
for vendorID: Vendor.IDValue,
on db: any Database
) async throws -> VendorBranch.DTO {
let branch = model.toModel()
guard let vendor = try await Vendor.find(vendorID, on: db) else {
throw Abort(.badRequest, reason: "Vendor does not exist.")
}
try await vendor.$branches.create(branch, on: db)
return branch.toDTO()
}
func fetchAll(withVendor: Bool? = nil, on db: any Database) async throws -> [VendorBranch.DTO] {
var query = VendorBranch.query(on: db)
if withVendor == true {
query = query.with(\.$vendor)
}
return try await query.all().map { $0.toDTO() }
}
func fetch(for vendorID: Vendor.IDValue, on db: any Database) async throws -> [VendorBranch.DTO] {
guard let vendor = try await Vendor.query(on: db)
.filter(\.$id == vendorID)
.with(\.$branches)
.first()
else {
throw Abort(.notFound)
}
return vendor.branches.map { $0.toDTO() }
}
func get(id: VendorBranch.IDValue, on db: any Database) async throws -> VendorBranch.DTO? {
try await VendorBranch.find(id, on: db).map { $0.toDTO() }
}
func update(
id: VendorBranch.IDValue,
with updates: VendorBranch.Update,
on db: any Database
) async throws -> VendorBranch.DTO {
guard let branch = try await VendorBranch.find(id, on: db) else {
throw Abort(.notFound)
}
branch.applyUpdates(updates)
try await branch.save(on: db)
return branch.toDTO()
}
func delete(id: VendorBranch.IDValue, on db: any Database) async throws {
guard let branch = try await VendorBranch.find(id, on: db) else {
throw Abort(.notFound)
}
try await branch.delete(on: db)
}
}

View File

@@ -0,0 +1,47 @@
import Fluent
import Vapor
struct VendorDB {
func create(_ model: Vendor.Create, on db: any Database) async throws -> Vendor.DTO {
let model = model.toModel()
try await model.save(on: db)
return model.toDTO()
}
func fetchAll(withBranches: Bool? = nil, on db: any Database) async throws -> [Vendor.DTO] {
var query = Vendor.query(on: db).sort(\.$name, .ascending)
if withBranches == true {
query = query.with(\.$branches)
}
return try await query.all().map { $0.toDTO(includeBranches: withBranches) }
}
func get(id: Vendor.IDValue, withBranches: Bool? = nil, on db: any Database) async throws -> Vendor.DTO? {
var query = Vendor.query(on: db).filter(\.$id == id)
if withBranches == true {
query = query.with(\.$branches)
}
return try await query.first().map { $0.toDTO(includeBranches: withBranches) }
}
func update(
id: Vendor.IDValue,
with updates: Vendor.Update,
on db: any Database
) async throws -> Vendor.DTO {
guard let vendor = try await Vendor.find(id, on: db) else {
throw Abort(.notFound)
}
vendor.applyUpdates(updates)
return vendor.toDTO()
}
func delete(id: Vendor.IDValue, on db: any Database) async throws {
guard let vendor = try await Vendor.find(id, on: db) else {
throw Abort(.notFound)
}
try await vendor.delete(on: db)
}
}

View File

@@ -12,4 +12,19 @@ extension RoutesBuilder {
}
)
}
func apiUnprotected(route: PathComponent) -> any RoutesBuilder {
grouped("api", "v1", route)
}
// Allows basic or token authentication for api routes and prefixes the
// given route with "/api/v1".
func apiProtected(route: PathComponent) -> any RoutesBuilder {
let prefixed = grouped("api", "v1", route)
return prefixed.grouped(
User.authenticator(),
UserToken.authenticator(),
User.guardMiddleware()
)
}
}