From 5efed277a1915c34dc8a83d2613613dd86ab1a85 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 6 Jan 2025 14:53:14 -0500 Subject: [PATCH] feat: Initial working api routes. --- .editorconfig | 7 + .swiftformat | 11 + .swiftlint.yml | 11 + Package.resolved | 294 +++++++++++++++++++ Sources/App/Controllers/ApiController.swift | 254 ++++++++++++++++ Sources/App/Controllers/TodoController.swift | 37 --- Sources/App/DTOs/TodoDTO.swift | 17 -- Sources/App/Migrations/CreateTodo.swift | 14 - Sources/App/Models/Employee.swift | 131 +++++++++ Sources/App/Models/PurchaseOrder.swift | 150 ++++++++++ Sources/App/Models/Todo.swift | 29 -- Sources/App/Models/User.swift | 92 ++++++ Sources/App/Models/UserToken.swift | 51 ++++ Sources/App/Models/Vendor.swift | 102 +++++++ Sources/App/Models/VendorBranch.swift | 102 +++++++ Sources/App/configure.swift | 24 +- Sources/App/routes.swift | 14 +- 17 files changed, 1227 insertions(+), 113 deletions(-) create mode 100644 .editorconfig create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 Package.resolved create mode 100644 Sources/App/Controllers/ApiController.swift delete mode 100644 Sources/App/Controllers/TodoController.swift delete mode 100644 Sources/App/DTOs/TodoDTO.swift delete mode 100644 Sources/App/Migrations/CreateTodo.swift create mode 100644 Sources/App/Models/Employee.swift create mode 100644 Sources/App/Models/PurchaseOrder.swift delete mode 100644 Sources/App/Models/Todo.swift create mode 100644 Sources/App/Models/User.swift create mode 100644 Sources/App/Models/UserToken.swift create mode 100644 Sources/App/Models/Vendor.swift create mode 100644 Sources/App/Models/VendorBranch.swift diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7cfbe01 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.swift] +indent_style = space +indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..08f338e --- /dev/null +++ b/.swiftformat @@ -0,0 +1,11 @@ +--self init-only +--indent 2 +--ifdef indent +--trimwhitespace always +--wraparguments before-first +--wrapparameters before-first +--wrapcollections preserve +--wrapconditions after-first +--typeblanklines preserve +--commas inline +--stripunusedargs closure-only diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..213129c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,11 @@ +disabled_rules: + - closing_brace + - fuction_body_length + - opening_brace + - nesting + +included: + - Sources + - Tests + +ignore_multiline_statement_conditions: true diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..d445755 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,294 @@ +{ + "originHash" : "c59e2480163811f4420fdf7da7e204df5b85f36e4d4106acb2ef4eedb9a90e2e", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "2119f0d9cc1b334e25447fe43d3693c0e60e6234", + "version" : "1.24.0" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", + "version" : "1.20.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "966d89ae64cd71c652a1e981bc971de59d64f13d", + "version" : "4.15.1" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "223b27d04ab2b51c25503c9922eecbcdf6c12f89", + "version" : "4.12.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "614d3ec27cdef50cfb9fc3cfd382b6a4d9578cff", + "version" : "1.49.0" + } + }, + { + "identity" : "fluent-sqlite-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-sqlite-driver.git", + "state" : { + "revision" : "6e3a5ff7f2cb733771a6bd71dd3a491cce79f24d", + "version" : "4.8.0" + } + }, + { + "identity" : "leaf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/leaf.git", + "state" : { + "revision" : "bf48d2423c00292b5937c60166c7db99705cae47", + "version" : "4.4.1" + } + }, + { + "identity" : "leaf-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/leaf-kit.git", + "state" : { + "revision" : "d0ca4417166ef7868d28ad21bc77d36b8735a0fc", + "version" : "1.11.1" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "8c9a227476555c55837e569be71944e02a056b72", + "version" : "4.9.1" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "e0b35ff07601465dd9f3af19a1c23083acaae3bd", + "version" : "3.32.0" + } + }, + { + "identity" : "sqlite-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-kit.git", + "state" : { + "revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f", + "version" : "4.5.2" + } + }, + { + "identity" : "sqlite-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-nio.git", + "state" : { + "revision" : "0c6a711c9779b5493364631e4f014618ef12a40a", + "version" : "1.10.5" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "7faebca1ea4f9aaf0cda1cef7c43aecd2311ddf6", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "ff0f781cf7c6a22d52957e50b104f5768b50c779", + "version" : "3.10.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "6483d340853a944c96dbcc28b27dd10b6c581703", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", + "version" : "1.6.2" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "e0165b53d49b413dd987526b641e05e246782685", + "version" : "2.5.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "dca6594f65308c761a9c409e09fbf35f48d50d34", + "version" : "2.77.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "2e9746cfc57554f70b650b021b6ae4738abef3e6", + "version" : "1.24.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "170f4ca06b6a9c57b811293cebcb96e81b661310", + "version" : "1.35.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "c7e95421334b1068490b5d41314a50e70bab23d1", + "version" : "2.29.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "bbd5e63cf949b7db0c9edaf7a21e141c52afe214", + "version" : "1.23.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "0c62c5b4601d6c125050b5c3a97f20cce881d32b", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", + "version" : "1.4.0" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "4d7456c0d4b33ef82783a90ecfeae33a52a3972a", + "version" : "4.111.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "4232d34efa49f633ba61afde365d3896fc7f8740", + "version" : "2.15.0" + } + } + ], + "version" : 3 +} diff --git a/Sources/App/Controllers/ApiController.swift b/Sources/App/Controllers/ApiController.swift new file mode 100644 index 0000000..aa8c90b --- /dev/null +++ b/Sources/App/Controllers/ApiController.swift @@ -0,0 +1,254 @@ +import Fluent +import Vapor + +struct ApiController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let api = routes.grouped("api", "v1") + let passwordProtected = api.grouped(User.authenticator(), User.guardMiddleware()) + + // Allows basic or token authentication. + let tokenProtected = api.grouped( + User.authenticator(), + UserToken.authenticator(), + User.guardMiddleware() + ) + + let employees = tokenProtected.grouped("employees") + let purchaseOrders = tokenProtected.grouped("purchase-orders") + + // TODO: Need to handle the intial creation of users somehow. + let users = tokenProtected.grouped("users") + let vendors = tokenProtected.grouped("vendors") + let vendorBranches = vendors.grouped(":vendorID", "branches") + + employees.get(use: employeesIndex(req:)) + employees.post(use: createEmployee(req:)) + employees.group(":employeeID") { + $0.delete(use: self.deleteEmployee(req:)) + $0.put(use: self.updateEmployee(req:)) + } + + purchaseOrders.get(use: purchaseOrdersIndex(req:)) + purchaseOrders.post(use: createPurchaseOrder(req:)) + + users.post(use: createUser(req:)) + passwordProtected.group("users", "login") { + $0.get(use: self.login(req:)) + } + + vendors.get(use: vendorsIndex) + vendors.post(use: createVendor) + vendors.group(":vendorID") { + $0.delete(use: self.deleteVendor) + $0.put(use: self.updateVendor(req:)) + } + + vendorBranches.get(use: branchesIndex(req:)) + vendorBranches.post(use: createBranch(req:)) + + vendorBranches.group(":branchID") { + $0.delete(use: self.deleteBranch(req:)) + $0.put(use: self.updateBranch(req:)) + } + } + + // MARK: - Employees + + @Sendable + func employeesIndex(req: Request) async throws -> [Employee.DTO] { + var dbQuery = Employee.query(on: req.db) + + let params = try req.query.decode(EmployeesIndexQuery.self) + if params.active == true { + dbQuery = dbQuery.filter(\.$active == true) + } + + return try await dbQuery.all().map { $0.toDTO() } + } + + @Sendable + func createEmployee(req: Request) async throws -> Employee.DTO { + try Employee.Create.validate(content: req) + let employee = try req.content.decode(Employee.Create.self).toModel() + try await employee.save(on: req.db) + return employee.toDTO() + } + + @Sendable + func deleteEmployee(req: Request) async throws -> HTTPStatus { + guard let employee = try await Employee.find(req.parameters.get("employeeID"), on: req.db) else { + throw Abort(.notFound) + } + try await employee.delete(on: req.db) + return .noContent + } + + @Sendable + func updateEmployee(req: Request) async throws -> Employee.DTO { + try Employee.Update.validate(content: req) + guard let employee = try await Employee.find(req.parameters.get("employeeID"), on: req.db) else { + throw Abort(.notFound) + } + let updates = try req.content.decode(Employee.Update.self) + employee.applyUpdates(updates) + try await employee.save(on: req.db) + return employee.toDTO() + } + + // MARK: - PurchaseOrders + + // TODO: Add pagination and filters. + @Sendable + func purchaseOrdersIndex(req: Request) async throws -> [PurchaseOrder.DTO] { + try await PurchaseOrder.query(on: req.db) + .with(\.$createdBy) + .with(\.$createdFor) + .with(\.$vendorBranch) { + $0.with(\.$vendor) + } + .all() + .map { $0.toDTO() } + } + + @Sendable + func createPurchaseOrder(req: Request) async throws -> PurchaseOrder.DTO { + try PurchaseOrder.Create.validate(content: req) + let createdById = try req.auth.require(User.self).requireID() + let create = try req.content.decode(PurchaseOrder.Create.self) + let purchaseOrder = create.toModel(createdByID: createdById) + try await purchaseOrder.save(on: req.db) + + guard let loaded = try await PurchaseOrder.query(on: req.db) + .filter(\.$id == purchaseOrder.id!) + .with(\.$createdBy) + .with(\.$createdFor) + .with(\.$vendorBranch, { branch in + branch.with(\.$vendor) + }) + .first() + else { + throw Abort(.noContent) + } + return loaded.toDTO() + } + + // MARK: - Users + + @Sendable + func createUser(req: Request) async throws -> User.DTO { + try User.Create.validate(content: req) + let create = try req.content.decode(User.Create.self) + guard create.password == create.confirmPassword else { + throw Abort(.badRequest, reason: "Passwords did not match.") + } + let user = try User( + username: create.username, + email: create.email, + passwordHash: Bcrypt.hash(create.password) + ) + try await user.save(on: req.db) + return user.toDTO() + } + + @Sendable + func login(req: Request) async throws -> UserToken { + let user = try req.auth.require(User.self) + let token = try user.generateToken() + try await token.save(on: req.db) + return token + } + + // MARK: - Vendors + + @Sendable + func vendorsIndex(req: Request) async throws -> [Vendor.DTO] { + var dbQuery = Vendor.query(on: req.db) + let params = try req.query.decode(VendorsIndexQuery.self) + + if params.branches == true { + dbQuery = dbQuery.with(\.$branches) + } + + return try await dbQuery.all().map { $0.toDTO(includeBranches: params.branches) } + } + + @Sendable + func createVendor(req: Request) async throws -> Vendor.DTO { + try Vendor.Create.validate(content: req) + let vendor = try req.content.decode(Vendor.Create.self).toModel() + try await vendor.save(on: req.db) + return vendor.toDTO() + } + + @Sendable + func updateVendor(req: Request) async throws -> Vendor.DTO { + try Vendor.Update.validate(content: req) + let updates = try req.content.decode(Vendor.Update.self) + guard let vendor = try await Vendor.find(req.parameters.get("vendorID"), on: req.db) else { + throw Abort(.notFound) + } + vendor.applyUpdates(updates) + try await vendor.save(on: req.db) + return vendor.toDTO() + } + + @Sendable + func deleteVendor(req: Request) async throws -> HTTPStatus { + guard let vendor = try await Vendor.find(req.parameters.get("vendorID"), on: req.db) else { + throw Abort(.notFound) + } + + try await vendor.delete(on: req.db) + return .noContent + } + + // MARK: - VendorBranch + + @Sendable + func branchesIndex(req: Request) async throws -> [VendorBranch.DTO] { + guard let vendor = try await Vendor.find(req.parameters.get("vendorID"), on: req.db) else { + throw Abort(.notFound) + } + return try await vendor.$branches.get(on: req.db).map { $0.toDTO() } + } + + @Sendable + func createBranch(req: Request) async throws -> VendorBranch.DTO { + try VendorBranch.Create.validate(content: req) + let branch = try req.content.decode(VendorBranch.Create.self).toModel() + guard let vendor = try await Vendor.find(req.parameters.get("vendorID"), on: req.db) else { + throw Abort(.notFound) + } + try await vendor.$branches.create(branch, on: req.db) + return branch.toDTO() + } + + @Sendable + func deleteBranch(req: Request) async throws -> HTTPStatus { + guard let branch = try await VendorBranch.find(req.parameters.get("branchID"), on: req.db) else { + throw Abort(.notFound) + } + try await branch.delete(on: req.db) + return .noContent + } + + @Sendable + func updateBranch(req: Request) async throws -> VendorBranch.DTO { + try VendorBranch.Update.validate(content: req) + let updates = try req.content.decode(VendorBranch.Update.self) + guard let branch = try await VendorBranch.find(req.parameters.get("branchID"), on: req.db) else { + throw Abort(.notFound) + } + branch.applyUpdates(updates) + try await branch.save(on: req.db) + return branch.toDTO() + } +} + +struct VendorsIndexQuery: Content { + let branches: Bool? +} + +struct EmployeesIndexQuery: Content { + let active: Bool? +} diff --git a/Sources/App/Controllers/TodoController.swift b/Sources/App/Controllers/TodoController.swift deleted file mode 100644 index 63fe661..0000000 --- a/Sources/App/Controllers/TodoController.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Fluent -import Vapor - -struct TodoController: RouteCollection { - func boot(routes: RoutesBuilder) throws { - let todos = routes.grouped("todos") - - todos.get(use: self.index) - todos.post(use: self.create) - todos.group(":todoID") { todo in - todo.delete(use: self.delete) - } - } - - @Sendable - func index(req: Request) async throws -> [TodoDTO] { - try await Todo.query(on: req.db).all().map { $0.toDTO() } - } - - @Sendable - func create(req: Request) async throws -> TodoDTO { - let todo = try req.content.decode(TodoDTO.self).toModel() - - try await todo.save(on: req.db) - return todo.toDTO() - } - - @Sendable - func delete(req: Request) async throws -> HTTPStatus { - guard let todo = try await Todo.find(req.parameters.get("todoID"), on: req.db) else { - throw Abort(.notFound) - } - - try await todo.delete(on: req.db) - return .noContent - } -} diff --git a/Sources/App/DTOs/TodoDTO.swift b/Sources/App/DTOs/TodoDTO.swift deleted file mode 100644 index c35ba33..0000000 --- a/Sources/App/DTOs/TodoDTO.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Fluent -import Vapor - -struct TodoDTO: Content { - var id: UUID? - var title: String? - - func toModel() -> Todo { - let model = Todo() - - model.id = self.id - if let title = self.title { - model.title = title - } - return model - } -} diff --git a/Sources/App/Migrations/CreateTodo.swift b/Sources/App/Migrations/CreateTodo.swift deleted file mode 100644 index bf6d945..0000000 --- a/Sources/App/Migrations/CreateTodo.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Fluent - -struct CreateTodo: AsyncMigration { - func prepare(on database: Database) async throws { - try await database.schema("todos") - .id() - .field("title", .string, .required) - .create() - } - - func revert(on database: Database) async throws { - try await database.schema("todos").delete() - } -} diff --git a/Sources/App/Models/Employee.swift b/Sources/App/Models/Employee.swift new file mode 100644 index 0000000..0d82dd8 --- /dev/null +++ b/Sources/App/Models/Employee.swift @@ -0,0 +1,131 @@ +import Fluent +import struct Foundation.UUID +import Vapor + +final class Employee: Model, @unchecked Sendable { + + static let schema = "employee" + + @ID(key: .id) + var id: UUID? + + @Field(key: "first_name") + var firstName: String + + @Field(key: "last_name") + var lastName: String + + @Field(key: "is_active") + var active: Bool + + init() {} + + init( + id: UUID? = nil, + firstName: String, + lastName: String, + active: Bool + ) { + self.id = id + self.firstName = firstName + self.lastName = lastName + self.active = active + } + + func toDTO() -> DTO { + .init(id: id, firstName: $firstName.value, lastName: $lastName.value, active: $active.value) + } + + func applyUpdates(_ updates: Update) { + if let firstName = updates.firstName { + self.firstName = firstName + } + if let lastName = updates.lastName { + self.lastName = lastName + } + if let active = updates.active { + self.active = active + } + } +} + +// MARK: - Helpers + +extension Employee { + + struct Create: Content { + let firstName: String + let lastName: String + let active: Bool? + + func toModel() -> Employee { + .init(firstName: firstName, lastName: lastName, active: active ?? true) + } + } + + struct DTO: Content { + + var id: UUID? + var firstName: String? + var lastName: String? + var active: Bool? + + func toModel() -> Employee { + let model = Employee() + + model.id = id + if let firstName { + model.firstName = firstName + } + if let lastName { + model.lastName = lastName + } + if let active { + model.active = active + } + return model + } + } + + struct Migrate: AsyncMigration { + + let name = "CreateEmployee" + + func prepare(on database: Database) async throws { + try await database.schema(Employee.schema) + .id() + .field("first_name", .string, .required) + .field("last_name", .string, .required) + .field("is_active", .bool, .required, .sql(.default(true))) + .unique(on: "first_name", "last_name") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(Employee.schema).delete() + } + + } + + struct Update: Content { + var firstName: String? + var lastName: String? + var active: Bool? + } +} + +// MARK: - Validations + +extension Employee.Create: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("firstName", as: String.self, is: !.empty) + validations.add("lastName", as: String.self, is: !.empty) + } +} + +extension Employee.Update: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("firstName", as: String?.self, is: .nil || !.empty, required: false) + validations.add("lastName", as: String?.self, is: .nil || !.empty, required: false) + } +} diff --git a/Sources/App/Models/PurchaseOrder.swift b/Sources/App/Models/PurchaseOrder.swift new file mode 100644 index 0000000..bbe8626 --- /dev/null +++ b/Sources/App/Models/PurchaseOrder.swift @@ -0,0 +1,150 @@ +import Fluent +import Vapor + +final class PurchaseOrder: Model, Content, @unchecked Sendable { + static let schema = "purchase_order" + + @ID(custom: "id", generatedBy: .database) + var id: Int? + + @Field(key: "work_order") + var workOrder: Int? + + @Field(key: "materials") + var materials: String + + @Field(key: "customer") + var customer: String + + @Field(key: "truck_stock") + var truckStock: Bool + + @Parent(key: "created_by_id") + var createdBy: User + + @Parent(key: "created_for_id") + var createdFor: Employee + + @Parent(key: "vendor_branch_id") + var vendorBranch: VendorBranch + + @Timestamp(key: "created_at", on: .create) + var createdAt: Date? + + @Timestamp(key: "updated_at", on: .update) + var updatedAt: Date? + + init() {} + + init( + id: Int? = nil, + workOrder: Int? = nil, + materials: String, + customer: String, + truckStock: Bool, + createdByID: User.IDValue, + createdForID: Employee.IDValue, + vendorBranchID: VendorBranch.IDValue, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.workOrder = workOrder + self.materials = materials + self.customer = customer + self.truckStock = truckStock + $createdBy.id = createdByID + $createdFor.id = createdForID + $vendorBranch.id = vendorBranchID + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + func toDTO() -> DTO { + .init( + id: id, + workOrder: workOrder, + materials: materials, + customer: customer, + truckStock: truckStock, + createdBy: $createdBy.value?.toDTO(), + createdFor: $createdFor.value?.toDTO(), + vendorBranch: $vendorBranch.value, + createdAt: createdAt, + updatedAt: updatedAt + ) + } + +} + +extension PurchaseOrder { + + struct Create: Content { + let workOrder: Int? + let materials: String + let customer: String + let truckStock: Bool? + let createdForID: Employee.IDValue + let vendorBranchID: VendorBranch.IDValue + + func toModel(createdByID: User.IDValue) -> PurchaseOrder { + .init( + id: nil, + workOrder: workOrder, + materials: materials, + customer: customer, + truckStock: truckStock ?? false, + createdByID: createdByID, + createdForID: createdForID, + vendorBranchID: vendorBranchID, + createdAt: nil, + updatedAt: nil + ) + } + } + + struct DTO: Content { + let id: Int? + let workOrder: Int? + let materials: String + let customer: String + let truckStock: Bool + let createdBy: User.DTO? + let createdFor: Employee.DTO? + let vendorBranch: VendorBranch? + let createdAt: Date? + let updatedAt: Date? + } + + struct Migrate: AsyncMigration { + + let name = "CreatePurchaseOrder" + + func prepare(on database: any Database) async throws { + try await database.schema(PurchaseOrder.schema) + .field("id", .int, .identifier(auto: true)) + .field("work_order", .int) + .field("customer", .string, .required) + .field("materials", .string, .required) + .field("truck_stock", .bool, .required) + .field("created_by_id", .uuid, .required, .references(User.schema, "id")) + .field("created_for_id", .uuid, .required, .references(Employee.schema, "id")) + .field("vendor_branch_id", .uuid, .required, .references(VendorBranch.schema, "id")) + .field("created_at", .datetime) + .field("updated_at", .datetime) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(PurchaseOrder.schema).delete() + } + } +} + +extension PurchaseOrder.Create: Validatable { + + static func validations(_ validations: inout Validations) { + validations.add("materials", as: String.self, is: !.empty) + validations.add("customer", as: String.self, is: !.empty) + } +} diff --git a/Sources/App/Models/Todo.swift b/Sources/App/Models/Todo.swift deleted file mode 100644 index de1837d..0000000 --- a/Sources/App/Models/Todo.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Fluent -import struct Foundation.UUID - -/// Property wrappers interact poorly with `Sendable` checking, causing a warning for the `@ID` property -/// It is recommended you write your model with sendability checking on and then suppress the warning -/// afterwards with `@unchecked Sendable`. -final class Todo: Model, @unchecked Sendable { - static let schema = "todos" - - @ID(key: .id) - var id: UUID? - - @Field(key: "title") - var title: String - - init() { } - - init(id: UUID? = nil, title: String) { - self.id = id - self.title = title - } - - func toDTO() -> TodoDTO { - .init( - id: self.id, - title: self.$title.value - ) - } -} diff --git a/Sources/App/Models/User.swift b/Sources/App/Models/User.swift new file mode 100644 index 0000000..7a831d3 --- /dev/null +++ b/Sources/App/Models/User.swift @@ -0,0 +1,92 @@ +import Fluent +import Vapor + +final class User: Model, @unchecked Sendable { + static let schema = "user" + + @ID(key: .id) + var id: UUID? + + @Field(key: "username") + var username: String + + @Field(key: "email") + var email: String + + @Field(key: "password_hash") + var passwordHash: String + + init() {} + + init(id: UUID? = nil, username: String, email: String, passwordHash: String) { + self.id = id + self.username = username + self.email = email + self.passwordHash = passwordHash + } + + func toDTO() -> DTO { + .init(id: id, username: $username.value, email: $email.value) + } + + func generateToken() throws -> UserToken { + try .init( + value: [UInt8].random(count: 16).base64, + userID: requireID() + ) + } + +} + +extension User { + + struct Create: Content { + var username: String + var email: String + var password: String + var confirmPassword: String + } + + struct DTO: Content { + let id: UUID? + let username: String? + let email: String? + } + + struct Migrate: AsyncMigration { + let name = "CreateUser" + + func prepare(on database: any Database) async throws { + try await database.schema(User.schema) + .id() + .field("username", .string, .required) + .field("email", .string, .required) + .field("password_hash", .string, .required) + .unique(on: "email", "username") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(User.schema).delete() + } + } +} + +extension User: ModelAuthenticatable { + static let usernameKey = \User.$email + static let passwordHashKey = \User.$passwordHash + + func verify(password: String) throws -> Bool { + try Bcrypt.verify(password, created: passwordHash) + } +} + +extension User: ModelSessionAuthenticatable {} + +extension User.Create: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("username", as: String.self, is: !.empty) + validations.add("email", as: String.self, is: .email) + validations.add("password", as: String.self, is: .count(8...)) + } +} diff --git a/Sources/App/Models/UserToken.swift b/Sources/App/Models/UserToken.swift new file mode 100644 index 0000000..5429044 --- /dev/null +++ b/Sources/App/Models/UserToken.swift @@ -0,0 +1,51 @@ +import Fluent +import Vapor + +final class UserToken: Model, Content, @unchecked Sendable { + + static let schema = "user_token" + + @ID(key: .id) + var id: UUID? + + @Field(key: "value") + var value: String + + @Parent(key: "user_id") + var user: User + + init() {} + + init(id: UUID? = nil, value: String, userID: User.IDValue) { + self.id = id + self.value = value + $user.id = userID + } +} + +extension UserToken { + + struct Migrate: AsyncMigration { + let name = "CreateUserToken" + + func prepare(on database: any Database) async throws { + try await database.schema(UserToken.schema) + .id() + .field("value", .string, .required) + .field("user_id", .uuid, .required, .references(User.schema, "id")) + .unique(on: "value") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(UserToken.schema).delete() + } + } +} + +extension UserToken: ModelTokenAuthenticatable { + static let valueKey = \UserToken.$value + static let userKey = \UserToken.$user + + var isValid: Bool { true } +} diff --git a/Sources/App/Models/Vendor.swift b/Sources/App/Models/Vendor.swift new file mode 100644 index 0000000..da5f7b7 --- /dev/null +++ b/Sources/App/Models/Vendor.swift @@ -0,0 +1,102 @@ +import Fluent +import struct Foundation.UUID +import Vapor + +// The primary database model. +final class Vendor: Model, @unchecked Sendable { + + static let schema = "vendor" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Children(for: \.$vendor) + var branches: [VendorBranch] + + init() {} + + init(id: UUID? = nil, name: String) { + self.id = id + self.name = name + } + + func toDTO(includeBranches: Bool? = nil) -> DTO { + .init( + id: id, + name: $name.value, + branches: ($branches.value != nil && $branches.value!.count > 0) + ? $branches.value!.map { $0.toDTO() } + : (includeBranches == true) ? [] : nil + ) + } + + func applyUpdates(_ updates: Update) { + name = updates.name + } +} + +// MARK: - Helpers. + +extension Vendor { + struct Create: Content { + var name: String + + func toModel() -> Vendor { + .init(name: name) + } + } + + struct DTO: Content { + + var id: UUID? + var name: String? + var branches: [VendorBranch.DTO]? + + func toModel() -> Vendor { + let model = Vendor() + model.id = id + if let name { + model.name = name + } + return model + } + } + + struct Migrate: AsyncMigration { + let name = "CreateVendor" + + func prepare(on database: Database) async throws { + try await database.schema(Vendor.schema) + .id() + .field("name", .string, .required) + .unique(on: "name") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(Vendor.schema).delete() + } + + } + + struct Update: Content { + var name: String + } +} + +// MARK: - Validations + +extension Vendor.Create: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("name", as: String.self, is: !.empty) + } +} + +extension Vendor.Update: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("name", as: String.self, is: !.empty) + } +} diff --git a/Sources/App/Models/VendorBranch.swift b/Sources/App/Models/VendorBranch.swift new file mode 100644 index 0000000..a2ad237 --- /dev/null +++ b/Sources/App/Models/VendorBranch.swift @@ -0,0 +1,102 @@ +import Fluent +import struct Foundation.UUID +import Vapor + +final class VendorBranch: Model, @unchecked Sendable { + + static let schema = "vendor_branch" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Parent(key: "vendor_id") + var vendor: Vendor + + init() {} + + init(id: UUID? = nil, name: String, vendorId: Vendor.IDValue) { + self.id = id + self.name = name + $vendor.id = vendorId + } + + func toDTO() -> DTO { + .init(id: id, name: $name.value, vendorId: $vendor.id) + } + + func applyUpdates(_ updates: Update) { + name = updates.name + } + +} + +// MARK: - Helpers + +extension VendorBranch { + struct Create: Content { + var name: String + + func toModel() -> VendorBranch { + let model = VendorBranch() + model.name = name + return model + } + } + + struct DTO: Content { + var id: UUID? + var name: String? + var vendorId: Vendor.IDValue? + + func toModel() -> VendorBranch { + let model = VendorBranch() + + model.id = id + if let name { + model.name = name + } + if let vendorId { + model.$vendor.id = vendorId + } + return model + } + + } + + struct Migrate: AsyncMigration { + let name = "CreateVendorBranch" + + func prepare(on database: Database) async throws { + try await database.schema(VendorBranch.schema) + .id() + .field("name", .string, .required) + .field("vendor_id", .uuid, .required, .references("vendor", "id")) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(VendorBranch.schema).delete() + } + } + + struct Update: Content { + var name: String + } +} + +// MARK: - Validations + +extension VendorBranch.Create: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("name", as: String.self, is: !.empty) + } +} + +extension VendorBranch.Update: Validatable { + static func validations(_ validations: inout Validations) { + validations.add("name", as: String.self, is: !.empty) + } +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 587ffc2..f7ea191 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,21 +1,27 @@ -import NIOSSL import Fluent import FluentSQLiteDriver import Leaf +import NIOSSL import Vapor // configures your application public func configure(_ app: Application) async throws { - // uncomment to serve files from /Public folder - // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + // uncomment to serve files from /Public folder + app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + app.middleware.use(app.sessions.middleware) + app.middleware.use(User.sessionAuthenticator()) - app.databases.use(DatabaseConfigurationFactory.sqlite(.file("db.sqlite")), as: .sqlite) + app.databases.use(DatabaseConfigurationFactory.sqlite(.file("db.sqlite")), as: .sqlite) - app.migrations.add(CreateTodo()) + app.migrations.add(Vendor.Migrate()) + app.migrations.add(VendorBranch.Migrate()) + app.migrations.add(Employee.Migrate()) + app.migrations.add(User.Migrate()) + app.migrations.add(UserToken.Migrate()) + app.migrations.add(PurchaseOrder.Migrate()) - app.views.use(.leaf) + app.views.use(.leaf) - - // register routes - try routes(app) + // register routes + try routes(app) } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index a159645..27cf8f6 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -2,13 +2,13 @@ import Fluent import Vapor func routes(_ app: Application) throws { - app.get { req async throws in - try await req.view.render("index", ["title": "Hello Vapor!"]) - } + app.get { req async throws in + try await req.view.render("index", ["title": "Hello Vapor!"]) + } - app.get("hello") { req async -> String in - "Hello, world!" - } + app.get("hello") { _ async -> String in + "Hello, world!" + } - try app.register(collection: TodoController()) + try app.register(collection: ApiController()) }