From f9808cadf611c3e00713e150e4f13d7a8dda4b82 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Fri, 10 May 2024 20:23:04 +0200 Subject: [PATCH 1/9] add gitignore lines for hummingbird --- .gitignore | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 483a1d0..6858261 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,13 @@ nb-configuration.xml # Rust - Rocket target build folder bookstore-rocketrs/target -bookstore-rocketrs/migration/target \ No newline at end of file +bookstore-rocketrs/migration/target + +# Swift - Projects in General +*/.build +*/DerivedData/ +*/.derivedData/ +*/.swiftpm + +# Swift - Hummingbird +bookstore-hummingbird/Packages From ea1eecd67d1964ade05450e5e7339d7bb4063b71 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Sun, 30 Jun 2024 09:04:18 +0200 Subject: [PATCH 2/9] base implementation --- bookstore-hummingbird/Package.resolved | 285 ++++++++++++++++++ bookstore-hummingbird/Package.swift | 92 ++++++ .../Sources/App/AppArguments.swift | 14 + .../Sources/App/AppBuilder.swift | 68 +++++ .../Sources/Common/Encodable+Dictionary.swift | 29 ++ .../Sources/Common/Environment.swift | 14 + .../Sources/Common/Secrets.swift | 55 ++++ .../Sources/Domain/BookController.swift | 105 +++++++ .../Sources/Domain/Controllable.swift | 13 + .../Extensions/Response+Encodable.swift | 25 ++ .../Sources/Executable/Executable.swift | 30 ++ .../Sources/Service/DatabaseMigrations.swift | 19 ++ .../Service/Migrations/CreateBookModels.swift | 27 ++ .../Modeldefinition/ModelDefinition.swift | 21 ++ .../Sources/Service/Models/BookModel.swift | 44 +++ .../Service/Service/RepositoryService.swift | 20 ++ .../Service/RepositoryServiceImpl.swift | 67 ++++ .../Service/RepositoryServiceModels.swift | 17 ++ .../Sources/Service/ServiceFactory.swift | 15 + .../Tests/DomainTests/File.swift | 8 + 20 files changed, 968 insertions(+) create mode 100644 bookstore-hummingbird/Package.resolved create mode 100644 bookstore-hummingbird/Package.swift create mode 100644 bookstore-hummingbird/Sources/App/AppArguments.swift create mode 100644 bookstore-hummingbird/Sources/App/AppBuilder.swift create mode 100644 bookstore-hummingbird/Sources/Common/Encodable+Dictionary.swift create mode 100644 bookstore-hummingbird/Sources/Common/Environment.swift create mode 100644 bookstore-hummingbird/Sources/Common/Secrets.swift create mode 100644 bookstore-hummingbird/Sources/Domain/BookController.swift create mode 100644 bookstore-hummingbird/Sources/Domain/Controllable.swift create mode 100644 bookstore-hummingbird/Sources/Domain/Extensions/Response+Encodable.swift create mode 100644 bookstore-hummingbird/Sources/Executable/Executable.swift create mode 100644 bookstore-hummingbird/Sources/Service/DatabaseMigrations.swift create mode 100644 bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift create mode 100644 bookstore-hummingbird/Sources/Service/Modeldefinition/ModelDefinition.swift create mode 100644 bookstore-hummingbird/Sources/Service/Models/BookModel.swift create mode 100644 bookstore-hummingbird/Sources/Service/Service/RepositoryService.swift create mode 100644 bookstore-hummingbird/Sources/Service/Service/RepositoryServiceImpl.swift create mode 100644 bookstore-hummingbird/Sources/Service/Service/RepositoryServiceModels.swift create mode 100644 bookstore-hummingbird/Sources/Service/ServiceFactory.swift create mode 100644 bookstore-hummingbird/Tests/DomainTests/File.swift diff --git a/bookstore-hummingbird/Package.resolved b/bookstore-hummingbird/Package.resolved new file mode 100644 index 0000000..fbec0a3 --- /dev/null +++ b/bookstore-hummingbird/Package.resolved @@ -0,0 +1,285 @@ +{ + "originHash" : "2d3c973cf241731b0a7a9b810e59dacd5dd610bfea931545e627de30c4009aac", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "a22083713ee90808d527d0baa290c2fb13ca3096", + "version" : "1.21.1" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "7ece208cd401687641c88367a00e3ea2b04311f1", + "version" : "1.19.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "d69efce21242ad4dba6935cc1b8d5637281604d5", + "version" : "1.48.5" + } + }, + { + "identity" : "fluent-postgres-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-postgres-driver.git", + "state" : { + "revision" : "e2988a8c960196eca2891f3a0bb1caad9044e7ea", + "version" : "2.9.2" + } + }, + { + "identity" : "fluent-sqlite-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-sqlite-driver.git", + "state" : { + "revision" : "40303a20bc39c270c8e50339ada30f9750e2a681", + "version" : "4.7.3" + } + }, + { + "identity" : "hummingbird", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird", + "state" : { + "revision" : "aed1e369c331f2c20780317e657d139e20522ada", + "version" : "2.0.0-beta.5" + } + }, + { + "identity" : "hummingbird-fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hummingbird-project/hummingbird-fluent.git", + "state" : { + "revision" : "7f3f075b1db45c4b24cce12dc5c92452b98684d9", + "version" : "2.0.0-beta.1" + } + }, + { + "identity" : "postgres-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-kit.git", + "state" : { + "revision" : "0b72fa83b1023c4b82072e4049a3db6c29781fff", + "version" : "2.13.5" + } + }, + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "ee669e9de721086d2dd8eef83a3a3ddda6904ec2", + "version" : "1.21.4" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "25d8170c31173c7db4ddfef473e257c3bde60783", + "version" : "3.30.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" : "1b03dafcd8b86047650925325a2bd4d20f6205fd", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36", + "version" : "1.0.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" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "bc1c29221f6dfeb0ebbfbc98eb95cd3d4967868e", + "version" : "3.4.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "11c756c5c4d7de0eeed8595695cadd7fa107aa19", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "9bee2fdb79cc740081abd8ebd80738063d632286", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version" : "1.5.4" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "ce594e71e92a1610015017f83f402894df540e51", + "version" : "2.4.4" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "359c461e5561d22c6334828806cc25d759ca7aa6", + "version" : "2.65.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "a3b640d7dc567225db7c94386a6e71aded1bfa63", + "version" : "1.22.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "c6afe04165c865faaa687b42c32ed76dfcc91076", + "version" : "1.31.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "7c381eb6083542b124a6c18fae742f55001dc2b5", + "version" : "2.26.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "38ac8221dd20674682148d6451367f89c2652980", + "version" : "1.21.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" : "ce0141c8f123132dbd02fd45fea448018762df1b", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "05d3852a55895cfbcdcf27a71fc69e291a61934c", + "version" : "2.5.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "f9266c85189c2751589a50ea5aec72799797e471", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/bookstore-hummingbird/Package.swift b/bookstore-hummingbird/Package.swift new file mode 100644 index 0000000..0439e11 --- /dev/null +++ b/bookstore-hummingbird/Package.swift @@ -0,0 +1,92 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "bookstore-hummingbird", + platforms: [ + .macOS(.v14), + ], + dependencies: [ + .argumentParser(), + .hummingbird(), + .fluent(), + .postgresDriver(), + .sqlDriver(), ], + targets: [ + .executableTarget(name: "executable", + dependencies: [ + .argumentParser(), + .target(name: "App"), + .target(name: "Common"), + ], + path: "Sources/Executable"), + .target(name: "App", + dependencies: [ + .hummingbird(), + .fluent(), + .postgresDriver(), + .sqlDriver(), + .target(name: "Domain"), + .target(name: "Service"), + .target(name: "Common"), + ], + path: "Sources/App"), + .target(name: "Domain", + dependencies: [ + .hummingbird(), + .target(name: "Service"), + ], + path: "Sources/Domain"), + .target(name: "Service", + dependencies: [ + .fluent(), + ], + path: "Sources/Service"), + .target(name: "Common", path: "Sources/Common") + ] +) + +extension Package.Dependency { + static func argumentParser() -> Package.Dependency { + Package.Dependency.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1") + } + + static func hummingbird() -> Package.Dependency { + Package.Dependency.package(url: "https://github.com/hummingbird-project/hummingbird", from: "2.0.0-beta.4") + } + + static func fluent() -> Package.Dependency { + Package.Dependency.package(url: "https://github.com/hummingbird-project/hummingbird-fluent.git", from: "2.0.0-beta.1") + } + + static func postgresDriver() -> Package.Dependency { + Package.Dependency.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0") + } + + static func sqlDriver() -> Package.Dependency { + Package.Dependency.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0") + } +} +extension PackageDescription.Target.Dependency { + static func argumentParser() -> Self { + PackageDescription.Target.Dependency.product(name: "ArgumentParser", package: "swift-argument-parser") + } + + static func hummingbird() -> Self { + PackageDescription.Target.Dependency.product(name: "Hummingbird", package: "hummingbird") + } + + static func fluent() -> Self { + PackageDescription.Target.Dependency.product(name: "HummingbirdFluent", package: "hummingbird-fluent") + } + + static func postgresDriver() -> Self { + PackageDescription.Target.Dependency.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver") + } + + static func sqlDriver() -> Self { + PackageDescription.Target.Dependency.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver") + } +} diff --git a/bookstore-hummingbird/Sources/App/AppArguments.swift b/bookstore-hummingbird/Sources/App/AppArguments.swift new file mode 100644 index 0000000..9732531 --- /dev/null +++ b/bookstore-hummingbird/Sources/App/AppArguments.swift @@ -0,0 +1,14 @@ +// +// AppArguments.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation + +public protocol AppArguments { + var hostname: String { get } + var port: Int { get } + var inMemoryDatabase: Bool { get } +} diff --git a/bookstore-hummingbird/Sources/App/AppBuilder.swift b/bookstore-hummingbird/Sources/App/AppBuilder.swift new file mode 100644 index 0000000..773845a --- /dev/null +++ b/bookstore-hummingbird/Sources/App/AppBuilder.swift @@ -0,0 +1,68 @@ +// +// AppBuilder.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation +import Hummingbird +import HummingbirdFluent +import FluentPostgresDriver +import FluentSQLiteDriver +import Service +import Common +import Domain + +public final class AppBuilder { + private let arguments: AppArguments + + public init(arguments: AppArguments) { + self.arguments = arguments + } + + public func make() async throws -> any ApplicationProtocol { + try Secrets.verifySetup() + let fluent: Fluent = makeFluent() + await DatabaseMigrations.addMigrations(to: fluent) + + let router = Router() + + let service = ServiceFactory.makeRepositoryService(database: fluent.db()) + + + let config = ApplicationConfiguration(address: .hostname(arguments.hostname, port: arguments.port)) + var app = Application(router: router, configuration: config) + + app.addServices(fluent) + + app.runBeforeServerStart { + try await fluent.migrate() + } + + return app + } + + private func makeFluent() -> Fluent { + let logger = Logger(label: "Fluent") + let fluent = Fluent(logger: logger) + + if arguments.inMemoryDatabase { + fluent.databases.use(.sqlite(.memory), as: .sqlite) + } else { + let config = createPostgresConfiguration() + fluent.databases.use(.postgres(configuration: config), as: .psql) + } + + return fluent + } + + private func createPostgresConfiguration() -> SQLPostgresConfiguration { + .init(hostname: Secrets.dbHostname, + port: Int(Secrets.dbPort) ?? 5432, + username: Secrets.dbUsername, + password: Secrets.dbPassword, + database: Secrets.dbName, + tls: .disable) + } +} diff --git a/bookstore-hummingbird/Sources/Common/Encodable+Dictionary.swift b/bookstore-hummingbird/Sources/Common/Encodable+Dictionary.swift new file mode 100644 index 0000000..d348ac1 --- /dev/null +++ b/bookstore-hummingbird/Sources/Common/Encodable+Dictionary.swift @@ -0,0 +1,29 @@ +// +// Encodable+Dictionary.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation + +public extension Encodable { + func toDict() -> [String: String] { + guard let data = try? JSONEncoder().encode(self), + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let dictionary = jsonObject as? [String: Any] else { + return [:] + } + + var stringDict: [String: String] = [:] + for (key, value) in dictionary { + if let stringValue = value as? String { + stringDict[key] = stringValue + } else { + stringDict[key] = "\(value)" + } + } + + return stringDict + } +} diff --git a/bookstore-hummingbird/Sources/Common/Environment.swift b/bookstore-hummingbird/Sources/Common/Environment.swift new file mode 100644 index 0000000..a6e9a30 --- /dev/null +++ b/bookstore-hummingbird/Sources/Common/Environment.swift @@ -0,0 +1,14 @@ +// +// Environment.swift +// +// +// Created by Moritz Ellerbrock +// +import Foundation + +enum Environment { + static func get(_ key: String) -> String? { + let env = ProcessInfo.processInfo.environment + return env[key] + } +} diff --git a/bookstore-hummingbird/Sources/Common/Secrets.swift b/bookstore-hummingbird/Sources/Common/Secrets.swift new file mode 100644 index 0000000..e2e77c1 --- /dev/null +++ b/bookstore-hummingbird/Sources/Common/Secrets.swift @@ -0,0 +1,55 @@ +// +// Secrets.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation + +public enum SecretsError: Error { + case secretNotSet(key: String) +} + +@dynamicMemberLookup +public struct Secrets: Encodable { + public let dbHostname = "DATABASE_HOST" + public let dbUsername = "DATABASE_USERNAME" + public let dbPassword = "DATABASE_PASSWORD" + public let dbPort = "DATABASE_POST" + public let dbName = "DATABASE_NAME" + + + public static subscript(dynamicMember keyPath: KeyPath) -> T { + let obj = Secrets() + let key = obj[keyPath: keyPath] as! String + let secret = obj.storedValue(for: key)! + return secret as! T + } + + func storedValue(for key: String) -> String? { + if key == dbHostname { + return Environment.get(key) ?? "localhost" + } else if key == dbUsername { + return Environment.get(key) ?? "postgres" + } else if key == dbPassword { + return Environment.get(key) ?? "postgres" + } else if key == dbPort { + return Environment.get(key) ?? "5432" + } else if key == dbName { + return Environment.get(key) ?? "postgres" + } else { + return nil + } + } + + public static func verifySetup() throws { + let obj = Secrets() + let dict = obj.toDict() + for key in dict.values { + guard let _ = obj.storedValue(for: key) else { + throw SecretsError.secretNotSet(key: key) + } + } + } +} diff --git a/bookstore-hummingbird/Sources/Domain/BookController.swift b/bookstore-hummingbird/Sources/Domain/BookController.swift new file mode 100644 index 0000000..28ec465 --- /dev/null +++ b/bookstore-hummingbird/Sources/Domain/BookController.swift @@ -0,0 +1,105 @@ +// +// BookController.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation +import Hummingbird +import Service + +struct BookController { + let service: RepositoryService + + init(service: RepositoryService) { + self.service = service + } +} + +extension BookController: Controllable { + func addRoutes(to router: Router) { + router + .group("books") + .get(use: getBooks) + .get(":bookId", use: getBook) + .post(use: createBook) + .delete(":bookId", use: deleteBook) + .delete(use: deleteAllBooks) + } + + @Sendable + private func getBooks(_ request: Request, context: BasicRequestContext) async throws -> Response { + guard + let limitString = request.uri.queryParameters.get("limit"), + let limit: Int = Int(limitString) + else { + return .init(status: .badRequest) + } + + let books = try await service.getBooks(limit: limit) + let codables = books.map { $0.toCodable() } + return Response(with: codables) + + } + + @Sendable + private func getBook(_ request: Request, context: BasicRequestContext) async throws -> Response { + let bookId = try context.parameters.require("bookId", as: UUID.self) + let book = try await service.getBook(with: bookId) + let codable = book?.toCodable() + return Response(with: codable) + } + + @Sendable + private func createBook(_ request: Request, context: BasicRequestContext) async throws -> Response { + let input = try await request.decode(as: Input.self, context: context) + let book = try await service.createBook(title: input.title, + author: input.author, + publisher: input.publisher, + releaseDate: input.releaseDate) + + let codable = book.toCodable() + return Response(with: codable) + } + + @Sendable + private func deleteBook(_ request: Request, context: BasicRequestContext) async throws -> Response { + let bookId = try context.parameters.require("bookId", as: UUID.self) + try await service.deleteBook(with: bookId) + return Response(with: nil) + } + + @Sendable + private func deleteAllBooks(_ request: Request, context: BasicRequestContext) async throws -> Response { + try await service.deleteAllBooks() + return Response(with: nil) + } +} + +extension BookController { + struct Input: Decodable { + let title: String + let author: String + let publisher: String + let releaseDate: Date + } + + struct Output: Codable { + let id: UUID + let title: String + let author: String + let publisher: String + let releaseDate: Date + } +} + +extension RepositoryServiceModels.Book { + func toCodable() -> BookController.Output { + .init(id: id, + title: title, + author: author, + publisher: publisher, + releaseDate: releaseDate) + } +} diff --git a/bookstore-hummingbird/Sources/Domain/Controllable.swift b/bookstore-hummingbird/Sources/Domain/Controllable.swift new file mode 100644 index 0000000..94120cb --- /dev/null +++ b/bookstore-hummingbird/Sources/Domain/Controllable.swift @@ -0,0 +1,13 @@ +// +// File.swift +// +// +// Created by Moritz Ellerbrock on 31.05.24. +// + +import Foundation +import Hummingbird + +public protocol Controllable { + func addRoutes(to router: Router) +} diff --git a/bookstore-hummingbird/Sources/Domain/Extensions/Response+Encodable.swift b/bookstore-hummingbird/Sources/Domain/Extensions/Response+Encodable.swift new file mode 100644 index 0000000..10467f9 --- /dev/null +++ b/bookstore-hummingbird/Sources/Domain/Extensions/Response+Encodable.swift @@ -0,0 +1,25 @@ +// +// Response+Encodable.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation +import HTTPTypes +import Hummingbird + +extension Response { + init( with codable: Codable?, status: HTTPResponse.Status = .ok, headers: HTTPFields = .init()) { + var body: ResponseBody = .init() + var headers: HTTPFields = headers + + if let codable, let data = try? JSONEncoder().encode(codable) { + let byteBuffer = ByteBuffer(data: data) + body = .init(byteBuffer: byteBuffer) + headers.append(.init(name: .contentType, value: "application/json")) + } + + self.init(status: status, headers: headers, body: body) + } +} diff --git a/bookstore-hummingbird/Sources/Executable/Executable.swift b/bookstore-hummingbird/Sources/Executable/Executable.swift new file mode 100644 index 0000000..731ac56 --- /dev/null +++ b/bookstore-hummingbird/Sources/Executable/Executable.swift @@ -0,0 +1,30 @@ +// +// Executable.swift +// +// +// Created by Moritz Ellerbrock +// + +import ArgumentParser +import App + +@main +final class Executable: AsyncParsableCommand, AppArguments { + @Option(name: .long) + var hostname: String = "127.0.0.1" + + @Option(name: .shortAndLong) + var port: Int = 8080 + + @Flag(name: .customLong("in-memory"), help: "Do you want to use an `in memory` database?") + var inMemoryDatabase: Bool = false + + + func run() async throws { + let appBuilder = AppBuilder(arguments: self) + let app = try await appBuilder.make() + try await app.runService() + } +} + + diff --git a/bookstore-hummingbird/Sources/Service/DatabaseMigrations.swift b/bookstore-hummingbird/Sources/Service/DatabaseMigrations.swift new file mode 100644 index 0000000..5f2722f --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/DatabaseMigrations.swift @@ -0,0 +1,19 @@ +// +// DatabaseMigrations.swift +// +// +// Created by Moritz Ellerbrock +// + +import FluentKit +import HummingbirdFluent + +public enum DatabaseMigrations { + public static func addMigrations(to fluent: Fluent) async { + var migrations: [any Migration] = [] + + migrations.append(CreateBookModels()) + + await fluent.migrations.add(migrations) + } +} diff --git a/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift b/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift new file mode 100644 index 0000000..14ec204 --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift @@ -0,0 +1,27 @@ +// +// CreateBookModels.swift +// +// +// Created by Moritz Ellerbrock +// + +import FluentKit + +struct CreateBookModels: AsyncMigration { + typealias BookFieldKeys = ModelDefinition.Book.FieldKeys + + func prepare(on database: any FluentKit.Database) async throws { + try await database.schema(ModelDefinition.Book.schema) + .id() + .field(BookFieldKeys.title, .string, .required) + .field(BookFieldKeys.author, .string, .required) + .field(BookFieldKeys.publisher, .string, .required) + .field(BookFieldKeys.releaseDate, .date, .required) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(ModelDefinition.Book.schema) + .delete() + } +} diff --git a/bookstore-hummingbird/Sources/Service/Modeldefinition/ModelDefinition.swift b/bookstore-hummingbird/Sources/Service/Modeldefinition/ModelDefinition.swift new file mode 100644 index 0000000..1f878aa --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/Modeldefinition/ModelDefinition.swift @@ -0,0 +1,21 @@ +// +// ModelDefinition.swift +// +// +// Created by Moritz Ellerbrock +// + +import FluentKit +import HummingbirdFluent + +enum ModelDefinition { + enum Book { + static var schema: String { "books" } + enum FieldKeys { + static var title: FieldKey { "title" } + static var author: FieldKey { "author" } + static var publisher: FieldKey { "publisher" } + static var releaseDate: FieldKey { "release_date" } + } + } +} diff --git a/bookstore-hummingbird/Sources/Service/Models/BookModel.swift b/bookstore-hummingbird/Sources/Service/Models/BookModel.swift new file mode 100644 index 0000000..75a6fbd --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/Models/BookModel.swift @@ -0,0 +1,44 @@ +// +// BookModel.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation +import FluentKit + +final class BookModel: Model { + typealias FieldKeyStore = ModelDefinition.Book.FieldKeys + + static var schema: String = ModelDefinition.Book.schema + + @ID(key: .id) + var id: UUID? + + @Field(key: FieldKeyStore.title) + var title: String + + @Field(key: FieldKeyStore.author) + var author: String + + @Field(key: FieldKeyStore.publisher) + var publisher: String + + @Field(key: FieldKeyStore.releaseDate) + var releaseDate: Date + + init() {} + + init(id: UUID? = nil, + title: String, + author: String, + publisher: String, + releaseDate: Date) { + self.id = id + self.title = title + self.author = author + self.publisher = publisher + self.releaseDate = releaseDate + } +} diff --git a/bookstore-hummingbird/Sources/Service/Service/RepositoryService.swift b/bookstore-hummingbird/Sources/Service/Service/RepositoryService.swift new file mode 100644 index 0000000..ed365c4 --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/Service/RepositoryService.swift @@ -0,0 +1,20 @@ +// +// RepositoryService.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation + +public protocol RepositoryService { + func createBook(title: String, author: String, publisher: String, releaseDate: Date) async throws -> RepositoryServiceModels.Book + + func getBook(with id: UUID) async throws -> RepositoryServiceModels.Book? + + func getBooks(limit: Int) async throws -> [RepositoryServiceModels.Book] + + func deleteBook(with id: UUID) async throws + + func deleteAllBooks() async throws +} diff --git a/bookstore-hummingbird/Sources/Service/Service/RepositoryServiceImpl.swift b/bookstore-hummingbird/Sources/Service/Service/RepositoryServiceImpl.swift new file mode 100644 index 0000000..facbf3f --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/Service/RepositoryServiceImpl.swift @@ -0,0 +1,67 @@ +// +// RepositoryServiceImpl.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation +import FluentKit + +class RepositoryServiceImpl { + private let db: any Database + + init(db: any Database) { + self.db = db + } + + private func query() -> QueryBuilder { + BookModel.query(on: self.db) + } + + private func query(_ id: UUID) -> QueryBuilder { + query().filter(\.$id == id) + } +} + +extension RepositoryServiceImpl: RepositoryService { + func createBook(title: String, author: String, publisher: String, releaseDate: Date) async throws -> RepositoryServiceModels.Book { + let model = BookModel(title: title, author: author, publisher: publisher, releaseDate: releaseDate) + + try await model.save(on: self.db) + + return try model.toDto() + } + + func getBook(with id: UUID) async throws -> RepositoryServiceModels.Book? { + let model = try await query(id).first() + return try model?.toDto() + } + + func getBooks(limit: Int) async throws -> [RepositoryServiceModels.Book] { + let models = try await query().limit(limit).all() + return try models.map { try $0.toDto() } + } + + func deleteBook(with id: UUID) async throws { + let model = try await query(id).first() + try await model?.delete(on: self.db) + } + + func deleteAllBooks() async throws { + let models = try await query().all() + try await models.delete(on: self.db) + } +} + + +private extension BookModel { + func toDto() throws -> RepositoryServiceModels.Book { + let id = try self.requireID() + return .init(id: id, + title: title, + author: author, + publisher: publisher, + releaseDate: releaseDate) + } +} diff --git a/bookstore-hummingbird/Sources/Service/Service/RepositoryServiceModels.swift b/bookstore-hummingbird/Sources/Service/Service/RepositoryServiceModels.swift new file mode 100644 index 0000000..2c29b79 --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/Service/RepositoryServiceModels.swift @@ -0,0 +1,17 @@ +// +// RepositoryServiceModels.swift +// +// +// Created by Moritz Ellerbrock +// +import Foundation + +public enum RepositoryServiceModels { + public struct Book { + public let id: UUID + public let title: String + public let author: String + public let publisher: String + public let releaseDate: Date + } +} diff --git a/bookstore-hummingbird/Sources/Service/ServiceFactory.swift b/bookstore-hummingbird/Sources/Service/ServiceFactory.swift new file mode 100644 index 0000000..24d3e68 --- /dev/null +++ b/bookstore-hummingbird/Sources/Service/ServiceFactory.swift @@ -0,0 +1,15 @@ +// +// DatabaseFactory.swift +// +// +// Created by Moritz Ellerbrock +// + +import Foundation +import FluentKit + +public enum ServiceFactory { + public static func makeRepositoryService(database: any Database) -> RepositoryService { + RepositoryServiceImpl(db: database) + } +} diff --git a/bookstore-hummingbird/Tests/DomainTests/File.swift b/bookstore-hummingbird/Tests/DomainTests/File.swift new file mode 100644 index 0000000..f9e6737 --- /dev/null +++ b/bookstore-hummingbird/Tests/DomainTests/File.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Moritz Ellerbrock on 30.06.24. +// + +import Foundation From e3b76e14358fe0a0c7cc50c357a10f0610c37057 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Sat, 20 Jul 2024 19:11:24 +0200 Subject: [PATCH 3/9] rename test file --- .../DomainTests/{File.swift => BookControllerTests.swift} | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename bookstore-hummingbird/Tests/DomainTests/{File.swift => BookControllerTests.swift} (68%) diff --git a/bookstore-hummingbird/Tests/DomainTests/File.swift b/bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift similarity index 68% rename from bookstore-hummingbird/Tests/DomainTests/File.swift rename to bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift index f9e6737..76bf42a 100644 --- a/bookstore-hummingbird/Tests/DomainTests/File.swift +++ b/bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift @@ -1,8 +1,9 @@ // -// File.swift -// +// BookControllerTests.swift +// // // Created by Moritz Ellerbrock on 30.06.24. // import Foundation + From 22fae245fd60a05666e74a77ea36d504f8dd799e Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Sun, 21 Jul 2024 11:20:57 +0200 Subject: [PATCH 4/9] add dockerfile to deploy and publish application --- .../workflows/publish-hummingbird-image.yml | 33 +++++++++ bookstore-hummingbird/.dockerignore | 4 ++ bookstore-hummingbird/Dockerfile | 72 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 .github/workflows/publish-hummingbird-image.yml create mode 100644 bookstore-hummingbird/.dockerignore create mode 100644 bookstore-hummingbird/Dockerfile diff --git a/.github/workflows/publish-hummingbird-image.yml b/.github/workflows/publish-hummingbird-image.yml new file mode 100644 index 0000000..93c8bd8 --- /dev/null +++ b/.github/workflows/publish-hummingbird-image.yml @@ -0,0 +1,33 @@ +name: Publish Hummingbird image to ECR + +on: + push: + branches: + - main + +jobs: + publish: + name: Publish Vapor Image + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@261a7de32bda11ba01f4d75c4ed6caf3739e54be + - name: Build, tag, and push image to Amazon ECR + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: bookstore-hummingbird + run: | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$GITHUB_SHA -t $ECR_REGISTRY/$ECR_REPOSITORY . + docker push -a $ECR_REGISTRY/$ECR_REPOSITORY + working-directory: ./bookstore-hummingbird diff --git a/bookstore-hummingbird/.dockerignore b/bookstore-hummingbird/.dockerignore new file mode 100644 index 0000000..07a3bb3 --- /dev/null +++ b/bookstore-hummingbird/.dockerignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +.derivedData +DerivedData \ No newline at end of file diff --git a/bookstore-hummingbird/Dockerfile b/bookstore-hummingbird/Dockerfile new file mode 100644 index 0000000..50f5d5f --- /dev/null +++ b/bookstore-hummingbird/Dockerfile @@ -0,0 +1,72 @@ +# ================================ +# Build image +# ================================ +FROM swift:5.10.1 as build + +# Install OS updates and, if needed, sqlite3 +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && rm -rf /var/lib/apt/lists/* + +# Set up a build area +WORKDIR /build + +# First just resolve dependencies. +COPY ./Package.* ./ +RUN swift package resolve --skip-update \ + "$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)" + +# Copy entire repo into container +COPY . . + +# Build everything, with optimizations +RUN swift build -c release --static-swift-stdlib + +# Switch to the staging area +WORKDIR /staging + +# Copy main executable to staging area +RUN cp "$(swift build --package-path /build -c release --show-bin-path)/executable" ./ + +# Copy resources bundled by SPM to staging area +RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; + +# Copy any resources from the public directory and views directory if the directories exist +# Ensure that by default, neither the directory nor any of its contents are writable. +RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true +RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true + +# ================================ +# Run image +# ================================ +FROM ubuntu:jammy + +# Make sure all system packages are up to date, and install only essential packages. +RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ + && apt-get -q update \ + && apt-get -q install -y \ + ca-certificates \ + tzdata \ + && rm -r /var/lib/apt/lists/* + +# Create a hummingbird user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=hummingbird:hummingbird /staging /app + +# Provide configuration needed by the built-in crash reporter and some sensible default behaviors. +ENV SWIFT_ROOT=/usr SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no + +# Ensure all further commands run as the hummingbird user +USER hummingbird:hummingbird + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT [ "/app/executable" ] +CMD ["--hostname", "0.0.0.0", "--port", "8080"] From cf11723760822f4c77df71880d1d4a7a4ca87db5 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Sun, 21 Jul 2024 15:15:24 +0200 Subject: [PATCH 5/9] add final touches to the setup --- README.md | 1 + .../Sources/App/AppBuilder.swift | 10 +++++----- .../Sources/Common/Secrets.swift | 16 ++++++++-------- .../Sources/Domain/DomainFactory.swift | 15 +++++++++++++++ bookstore-hummingbird/docker-compose.yml | 13 +++++++++++++ 5 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 bookstore-hummingbird/Sources/Domain/DomainFactory.swift create mode 100644 bookstore-hummingbird/docker-compose.yml diff --git a/README.md b/README.md index cb2397f..4513016 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Currently you can find the following implementations of the Book Store REST API: 3) [Spring Boot](./bookstore-springboot) 4) [NestJS](./bookstore-nestjs) 5) [Rust](./bookstore-actix) +6) [Hummingbird(swift)](./bookstore-hummingbird) ## Contribute diff --git a/bookstore-hummingbird/Sources/App/AppBuilder.swift b/bookstore-hummingbird/Sources/App/AppBuilder.swift index 773845a..2ed9db0 100644 --- a/bookstore-hummingbird/Sources/App/AppBuilder.swift +++ b/bookstore-hummingbird/Sources/App/AppBuilder.swift @@ -26,19 +26,19 @@ public final class AppBuilder { let fluent: Fluent = makeFluent() await DatabaseMigrations.addMigrations(to: fluent) - let router = Router() - let service = ServiceFactory.makeRepositoryService(database: fluent.db()) + let router = Router() + + let controller = DomainFactory.makeBookController(service: service) + controller.addRoutes(to: router) let config = ApplicationConfiguration(address: .hostname(arguments.hostname, port: arguments.port)) var app = Application(router: router, configuration: config) app.addServices(fluent) + try await fluent.migrate() - app.runBeforeServerStart { - try await fluent.migrate() - } return app } diff --git a/bookstore-hummingbird/Sources/Common/Secrets.swift b/bookstore-hummingbird/Sources/Common/Secrets.swift index e2e77c1..5f7b5a4 100644 --- a/bookstore-hummingbird/Sources/Common/Secrets.swift +++ b/bookstore-hummingbird/Sources/Common/Secrets.swift @@ -13,11 +13,11 @@ public enum SecretsError: Error { @dynamicMemberLookup public struct Secrets: Encodable { - public let dbHostname = "DATABASE_HOST" - public let dbUsername = "DATABASE_USERNAME" - public let dbPassword = "DATABASE_PASSWORD" - public let dbPort = "DATABASE_POST" - public let dbName = "DATABASE_NAME" + public let dbHostname = "POSTGRES_HOST" + public let dbUsername = "POSTGRES_USER" + public let dbPassword = "POSTGRES_PASSWORD" + public let dbPort = "POSTGRES_PORT" + public let dbName = "POSTGRES_DB" public static subscript(dynamicMember keyPath: KeyPath) -> T { @@ -31,13 +31,13 @@ public struct Secrets: Encodable { if key == dbHostname { return Environment.get(key) ?? "localhost" } else if key == dbUsername { - return Environment.get(key) ?? "postgres" + return Environment.get(key) } else if key == dbPassword { - return Environment.get(key) ?? "postgres" + return Environment.get(key) } else if key == dbPort { return Environment.get(key) ?? "5432" } else if key == dbName { - return Environment.get(key) ?? "postgres" + return Environment.get(key) } else { return nil } diff --git a/bookstore-hummingbird/Sources/Domain/DomainFactory.swift b/bookstore-hummingbird/Sources/Domain/DomainFactory.swift new file mode 100644 index 0000000..75c5cca --- /dev/null +++ b/bookstore-hummingbird/Sources/Domain/DomainFactory.swift @@ -0,0 +1,15 @@ +// +// File.swift +// bookstore-hummingbird +// +// Created by Moritz Ellerbrock on 21.07.24. +// + +import Foundation +import Service + +public enum DomainFactory { + public static func makeBookController(service: RepositoryService) -> Controllable { + BookController(service: service) + } +} diff --git a/bookstore-hummingbird/docker-compose.yml b/bookstore-hummingbird/docker-compose.yml new file mode 100644 index 0000000..2d3b1af --- /dev/null +++ b/bookstore-hummingbird/docker-compose.yml @@ -0,0 +1,13 @@ +services: + humingbirdapp: + container_name: humingbirdapp + image: bookstore-hummingbird + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:8080" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: secret + POSTGRES_DB: books-db From ff55edda199f768c99aa0a23e05b2bb85cf49382 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Fri, 26 Jul 2024 08:34:17 +0200 Subject: [PATCH 6/9] fix setup --- .../Sources/App/AppBuilder.swift | 1 + .../Sources/Common/DateFormat.swift | 90 +++++++++++++++++++ .../Sources/Common/Secrets.swift | 5 +- .../Sources/Domain/BookController.swift | 22 ++++- .../Sources/Executable/Executable.swift | 2 +- bookstore-hummingbird/docker-compose.yml | 8 +- 6 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 bookstore-hummingbird/Sources/Common/DateFormat.swift diff --git a/bookstore-hummingbird/Sources/App/AppBuilder.swift b/bookstore-hummingbird/Sources/App/AppBuilder.swift index 2ed9db0..4410eb3 100644 --- a/bookstore-hummingbird/Sources/App/AppBuilder.swift +++ b/bookstore-hummingbird/Sources/App/AppBuilder.swift @@ -25,6 +25,7 @@ public final class AppBuilder { try Secrets.verifySetup() let fluent: Fluent = makeFluent() await DatabaseMigrations.addMigrations(to: fluent) + try await fluent.migrate() let service = ServiceFactory.makeRepositoryService(database: fluent.db()) diff --git a/bookstore-hummingbird/Sources/Common/DateFormat.swift b/bookstore-hummingbird/Sources/Common/DateFormat.swift new file mode 100644 index 0000000..1f7c14d --- /dev/null +++ b/bookstore-hummingbird/Sources/Common/DateFormat.swift @@ -0,0 +1,90 @@ +// +// DateFormatter.swift +// +// +// Created by Moritz Ellerbrock on 08.06.23. +// + +import Foundation + +public enum DateFormat: CaseIterable { + case yearMontDay + case YearMonthDayHoursMinutesSeconds + case YearMonthDayHoursMinutesSecondsAndTimeZone + case dayMonthYear + case dayAbbreviatedMonthYear + case fullMonthDayYear + case fullWeekdayFullMonthNameDayYear + case hoursMinutesWithAmPmIndicator + case hoursMinutesSecondsIn24hFormat + case iso8601Format + case abbreviatedMonthDayYearTimeInAmPmFormat + case abbreviatedMonthDayYearTimeIn24hFormat + + private var format: String { + switch self { + case .yearMontDay: + return "yyyy-MM-dd" + case .YearMonthDayHoursMinutesSeconds: + return "yyyy-MM-dd HH:mm:ss" + case .YearMonthDayHoursMinutesSecondsAndTimeZone: + return "yyyy.MM.dd_HH-mm-ss-ZZZZ" + case .dayMonthYear: + return "dd/MM/yyyy" + case .dayAbbreviatedMonthYear: + return "dd MMM yyyy" + case .fullMonthDayYear: + return "MMMM dd, yyyy" + case .fullWeekdayFullMonthNameDayYear: + return "EEEE, MMMM dd, yyyy" + case .hoursMinutesWithAmPmIndicator: + return "h:mm a" + case .hoursMinutesSecondsIn24hFormat: + return "HH:mm:ss" + case .iso8601Format: + return "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + case .abbreviatedMonthDayYearTimeInAmPmFormat: + return "MMM dd, yyyy 'at' h:mm a" + case .abbreviatedMonthDayYearTimeIn24hFormat: + return "MMM dd, yyyy 'at' h:mm:ss" + } + } + + private var dateFormatter: DateFormatter { + let dateformatter = DateFormatter() + dateformatter.dateFormat = format + return dateformatter + } + + public static func date(from string: String) -> Date? { + for format in allCases { + if let date = format.date(from: string) { + return date + } + } + return nil + } + + public func string(from date: Date) -> String { + dateFormatter.string(from: date) + } + + public func date(from string: String) -> Date? { + guard let date = dateFormatter.date(from: string) else { + return nil + } + return date + } +} + +public extension Date { + func string(with format: DateFormat) -> String { + format.string(from: self) + } +} + +public extension String { + func date(with format: DateFormat) -> Date? { + format.date(from: self) + } +} diff --git a/bookstore-hummingbird/Sources/Common/Secrets.swift b/bookstore-hummingbird/Sources/Common/Secrets.swift index 5f7b5a4..0607471 100644 --- a/bookstore-hummingbird/Sources/Common/Secrets.swift +++ b/bookstore-hummingbird/Sources/Common/Secrets.swift @@ -18,6 +18,7 @@ public struct Secrets: Encodable { public let dbPassword = "POSTGRES_PASSWORD" public let dbPort = "POSTGRES_PORT" public let dbName = "POSTGRES_DB" + public let dbUrl = "POSTGRES_URL" public static subscript(dynamicMember keyPath: KeyPath) -> T { @@ -38,7 +39,9 @@ public struct Secrets: Encodable { return Environment.get(key) ?? "5432" } else if key == dbName { return Environment.get(key) - } else { + } else if key == dbUrl { + return Environment.get(key) ?? "postgres://postgres:secret@postgres/books-db" + } else{ return nil } } diff --git a/bookstore-hummingbird/Sources/Domain/BookController.swift b/bookstore-hummingbird/Sources/Domain/BookController.swift index 28ec465..c71d323 100644 --- a/bookstore-hummingbird/Sources/Domain/BookController.swift +++ b/bookstore-hummingbird/Sources/Domain/BookController.swift @@ -7,6 +7,7 @@ import Foundation import Hummingbird +import Common import Service struct BookController { @@ -82,7 +83,16 @@ extension BookController { let title: String let author: String let publisher: String - let releaseDate: Date + private let releaseDateString: String + var releaseDate: Date { + releaseDateString.date(with: .yearMontDay)! + } + enum CodingKeys: String, CodingKey { + case title + case author + case publisher + case releaseDateString = "releaseDate" + } } struct Output: Codable { @@ -90,7 +100,15 @@ extension BookController { let title: String let author: String let publisher: String - let releaseDate: Date + let releaseDate: String + + init(id: UUID, title: String, author: String, publisher: String, releaseDate: Date) { + self.id = id + self.title = title + self.author = author + self.publisher = publisher + self.releaseDate = releaseDate.string(with: .yearMontDay) + } } } diff --git a/bookstore-hummingbird/Sources/Executable/Executable.swift b/bookstore-hummingbird/Sources/Executable/Executable.swift index 731ac56..932940a 100644 --- a/bookstore-hummingbird/Sources/Executable/Executable.swift +++ b/bookstore-hummingbird/Sources/Executable/Executable.swift @@ -14,7 +14,7 @@ final class Executable: AsyncParsableCommand, AppArguments { var hostname: String = "127.0.0.1" @Option(name: .shortAndLong) - var port: Int = 8080 + var port: Int = 3000 @Flag(name: .customLong("in-memory"), help: "Do you want to use an `in memory` database?") var inMemoryDatabase: Bool = false diff --git a/bookstore-hummingbird/docker-compose.yml b/bookstore-hummingbird/docker-compose.yml index 2d3b1af..72f1d42 100644 --- a/bookstore-hummingbird/docker-compose.yml +++ b/bookstore-hummingbird/docker-compose.yml @@ -6,8 +6,14 @@ services: context: . dockerfile: Dockerfile ports: - - "3000:8080" + - "3000:3000" environment: + POSTGRES_HOST: 192.168.2.175 POSTGRES_USER: postgres POSTGRES_PASSWORD: secret POSTGRES_DB: books-db + deploy: + resources: + limits: + memory: 1024M + cpus: "1.0" From 78dae1ed23fd5480243f69fcb3f5498e9a675067 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Sun, 18 Aug 2024 17:28:53 +0200 Subject: [PATCH 7/9] add verification script --- check_implementation.sh | 133 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100755 check_implementation.sh diff --git a/check_implementation.sh b/check_implementation.sh new file mode 100755 index 0000000..51d167c --- /dev/null +++ b/check_implementation.sh @@ -0,0 +1,133 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use JSON qw(decode_json); +use LWP::UserAgent; +use HTTP::Request; + +# Check if the port argument is provided +my $port = shift || 3000; + +print "Using port $port\n"; +my $base_url = "http://localhost:$port"; +my $limit = 10; # Adjust the limit as needed + +# Function to call an endpoint and collect data +sub call_endpoint { + my ($method, $endpoint, $data) = @_; + my $url = "$base_url$endpoint"; + my $ua = LWP::UserAgent->new; + + my $req; + if ($method eq 'GET') { + $req = HTTP::Request->new(GET => $url); + } elsif ($method eq 'POST') { + $req = HTTP::Request->new(POST => $url); + $req->header('Content-Type' => 'application/json'); + $req->content($data); + } elsif ($method eq 'DELETE') { + $req = HTTP::Request->new(DELETE => $url); + } + + my $response = $ua->request($req); + return $response->decoded_content; +} + +# Function to remove the 'id' field from JSON object +sub remove_id { + my $json_str = shift; + my $json_data = decode_json($json_str); + delete $json_data->{id}; + return $json_data; +} + +# Function to compare two JSON objects by key-value pairs +sub compare_json_objects { + my ($fetched, $expected) = @_; + + foreach my $key (keys %$expected) { + if (!exists $fetched->{$key} || $fetched->{$key} ne $expected->{$key}) { + return 0; + } + } + + return 1; +} + +# Initial book data to populate the DB +my @books = ( + '{"title":"The Silent Horizon","author":"Alex Mercer","publisher":"Mercer Publishing","releaseDate":"2020-01-15"}', + '{"title":"Whispers of the Past","author":"Bethany Cole","publisher":"Cole Books","releaseDate":"2019-05-23"}', + '{"title":"Echoes in the Valley","author":"Carlos Reyes","publisher":"Reyes House","releaseDate":"2018-11-30"}', + '{"title":"The Last Light","author":"Diana Winters","publisher":"Winter Reads","releaseDate":"2021-07-20"}', + '{"title":"Mystery of the Lake","author":"Ethan Brown","publisher":"Brown & Co.","releaseDate":"2017-09-14"}', + '{"title":"Journey to the Unknown","author":"Fiona Hart","publisher":"Hart Publishing","releaseDate":"2022-03-19"}', + '{"title":"Secrets of the Forest","author":"Gavin Knight","publisher":"Knight Books","releaseDate":"2021-12-11"}', + '{"title":"Shadows of the Mind","author":"Hannah White","publisher":"White Pages","releaseDate":"2020-10-25"}', + '{"title":"Beyond the Stars","author":"Isaac Green","publisher":"Greenlight Books","releaseDate":"2016-08-05"}', + '{"title":"Dreams of Tomorrow","author":"Julia Black","publisher":"Black Ink","releaseDate":"2019-04-09"}' +); + +# Array to store book UUIDs +my @book_ids; + +# Create initial books +print "Creating initial books...\n"; +foreach my $book (@books) { + my $response = call_endpoint('POST', '/books', $book); + my $book_data = decode_json($response); + push @book_ids, $book_data->{id}; +} + +# Fetch the list of books and store the response +my $book_list = call_endpoint('GET', "/books?limit=$limit"); +print "Fetched book list:\n$book_list\n"; +print "----------------------------\n"; + +# Function to verify if fetched book matches expected book data +sub verify_book { + my ($book_id, $expected_book) = @_; + my $fetched_book = call_endpoint('GET', "/books/$book_id"); + my $fetched_data = remove_id($fetched_book); + my $expected_data = decode_json($expected_book); + + if (compare_json_objects($fetched_data, $expected_data)) { + print "Book $book_id verification PASSED\n"; + } else { + print "Book $book_id verification FAILED\n"; + print "Expected: ", encode_json($expected_data), "\n"; + print "Fetched: ", encode_json($fetched_data), "\n"; + } + print "----------------------------\n"; +} + +# Verify each created book by its UUID +for (my $i = 0; $i < scalar @book_ids; $i++) { + verify_book($book_ids[$i], $books[$i]); +} + +# Test if the limit parameter is applied correctly +my $test_limit = 5; +my $limited_books = call_endpoint('GET', "/books?limit=$test_limit"); +my $limited_books_data = decode_json($limited_books); +my $limited_books_count = scalar @$limited_books_data; + +if ($limited_books_count == $test_limit) { + print "Limit parameter test PASSED: Fetched $limited_books_count books with limit $test_limit.\n"; +} else { + print "Limit parameter test FAILED: Expected $test_limit books, but got $limited_books_count.\n"; +} + +print "----------------------------\n"; + +# Delete all books using their UUIDs +print "Deleting all books...\n"; +foreach my $book_id (@book_ids) { + call_endpoint('DELETE', "/books/$book_id"); +} + +# Fetch the list of books again to verify all books are deleted +my $final_book_list = call_endpoint('GET', "/books?limit=$limit"); +print "Final book list after deletion:\n$final_book_list\n"; +print "----------------------------\n"; From d5c2e06762ba9a6c4bf73929d7e31e539cf42f3a Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Sun, 18 Aug 2024 17:31:55 +0200 Subject: [PATCH 8/9] fix implementation --- .../Sources/Common/DateFormat.swift | 95 ++++++++++++++----- .../Sources/Domain/BookController.swift | 82 ++++++++++------ .../Service/Migrations/CreateBookModels.swift | 2 +- 3 files changed, 123 insertions(+), 56 deletions(-) diff --git a/bookstore-hummingbird/Sources/Common/DateFormat.swift b/bookstore-hummingbird/Sources/Common/DateFormat.swift index 1f7c14d..d1f640e 100644 --- a/bookstore-hummingbird/Sources/Common/DateFormat.swift +++ b/bookstore-hummingbird/Sources/Common/DateFormat.swift @@ -8,7 +8,7 @@ import Foundation public enum DateFormat: CaseIterable { - case yearMontDay + case yearMonthDay case YearMonthDayHoursMinutesSeconds case YearMonthDayHoursMinutesSecondsAndTimeZone case dayMonthYear @@ -23,39 +23,82 @@ public enum DateFormat: CaseIterable { private var format: String { switch self { - case .yearMontDay: - return "yyyy-MM-dd" - case .YearMonthDayHoursMinutesSeconds: - return "yyyy-MM-dd HH:mm:ss" - case .YearMonthDayHoursMinutesSecondsAndTimeZone: - return "yyyy.MM.dd_HH-mm-ss-ZZZZ" - case .dayMonthYear: - return "dd/MM/yyyy" - case .dayAbbreviatedMonthYear: - return "dd MMM yyyy" - case .fullMonthDayYear: - return "MMMM dd, yyyy" - case .fullWeekdayFullMonthNameDayYear: - return "EEEE, MMMM dd, yyyy" - case .hoursMinutesWithAmPmIndicator: - return "h:mm a" - case .hoursMinutesSecondsIn24hFormat: - return "HH:mm:ss" - case .iso8601Format: - return "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - case .abbreviatedMonthDayYearTimeInAmPmFormat: - return "MMM dd, yyyy 'at' h:mm a" - case .abbreviatedMonthDayYearTimeIn24hFormat: - return "MMM dd, yyyy 'at' h:mm:ss" + case .yearMonthDay: + return "yyyy-MM-dd" + case .YearMonthDayHoursMinutesSeconds: + return "yyyy-MM-dd HH:mm:ss" + case .YearMonthDayHoursMinutesSecondsAndTimeZone: + return "yyyy.MM.dd_HH-mm-ss-ZZZZ" + case .dayMonthYear: + return "dd/MM/yyyy" + case .dayAbbreviatedMonthYear: + return "dd MMM yyyy" + case .fullMonthDayYear: + return "MMMM dd, yyyy" + case .fullWeekdayFullMonthNameDayYear: + return "EEEE, MMMM dd, yyyy" + case .hoursMinutesWithAmPmIndicator: + return "h:mm a" + case .hoursMinutesSecondsIn24hFormat: + return "HH:mm:ss" + case .iso8601Format: + return "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + case .abbreviatedMonthDayYearTimeInAmPmFormat: + return "MMM dd, yyyy 'at' h:mm a" + case .abbreviatedMonthDayYearTimeIn24hFormat: + return "MMM dd, yyyy 'at' h:mm:ss" + } + } + + private var hasTimeValues: Bool { + switch self { + case .yearMonthDay: + false + case .YearMonthDayHoursMinutesSeconds: + true + case .YearMonthDayHoursMinutesSecondsAndTimeZone: + true + case .dayMonthYear: + false + case .dayAbbreviatedMonthYear: + false + case .fullMonthDayYear: + false + case .fullWeekdayFullMonthNameDayYear: + false + case .hoursMinutesWithAmPmIndicator: + false + case .hoursMinutesSecondsIn24hFormat: + true + case .iso8601Format: + true + case .abbreviatedMonthDayYearTimeInAmPmFormat: + true + case .abbreviatedMonthDayYearTimeIn24hFormat: + true } } private var dateFormatter: DateFormatter { let dateformatter = DateFormatter() dateformatter.dateFormat = format + dateformatter.locale = Locale.current return dateformatter } + private func resetTimeToMidnight(for date: Date) -> Date { + guard !self.hasTimeValues else { return date } + + // Get the current calendar + let calendar = Calendar.current + + // Extract the year, month, and day components + let components = calendar.dateComponents([.year, .month, .day], from: date) + + // Create a new date with the same year, month, and day, but with time set to 00:00:00 + return calendar.date(from: components) ?? date + } + public static func date(from string: String) -> Date? { for format in allCases { if let date = format.date(from: string) { @@ -73,7 +116,7 @@ public enum DateFormat: CaseIterable { guard let date = dateFormatter.date(from: string) else { return nil } - return date + return resetTimeToMidnight(for: date) } } diff --git a/bookstore-hummingbird/Sources/Domain/BookController.swift b/bookstore-hummingbird/Sources/Domain/BookController.swift index c71d323..3c9cf1d 100644 --- a/bookstore-hummingbird/Sources/Domain/BookController.swift +++ b/bookstore-hummingbird/Sources/Domain/BookController.swift @@ -31,50 +31,74 @@ extension BookController: Controllable { @Sendable private func getBooks(_ request: Request, context: BasicRequestContext) async throws -> Response { - guard - let limitString = request.uri.queryParameters.get("limit"), - let limit: Int = Int(limitString) - else { - return .init(status: .badRequest) + do { + guard + let limitString = request.uri.queryParameters.get("limit"), + let limit: Int = Int(limitString) + else { + return .init(status: .badRequest) + } + + let books = try await service.getBooks(limit: limit) + let codables = books.map { $0.toCodable() } + return Response(with: codables) + } catch { + print(String(reflecting: error)) + throw error } - - let books = try await service.getBooks(limit: limit) - let codables = books.map { $0.toCodable() } - return Response(with: codables) - } @Sendable private func getBook(_ request: Request, context: BasicRequestContext) async throws -> Response { - let bookId = try context.parameters.require("bookId", as: UUID.self) - let book = try await service.getBook(with: bookId) - let codable = book?.toCodable() - return Response(with: codable) + do { + let bookId = try context.parameters.require("bookId", as: UUID.self) + let book = try await service.getBook(with: bookId) + let codable = book?.toCodable() + return Response(with: codable) + } catch { + print(String(reflecting: error)) + throw error + } } @Sendable private func createBook(_ request: Request, context: BasicRequestContext) async throws -> Response { - let input = try await request.decode(as: Input.self, context: context) - let book = try await service.createBook(title: input.title, - author: input.author, - publisher: input.publisher, - releaseDate: input.releaseDate) - - let codable = book.toCodable() - return Response(with: codable) + do { + let input = try await request.decode(as: Input.self, context: context) + let book = try await service.createBook(title: input.title, + author: input.author, + publisher: input.publisher, + releaseDate: input.releaseDate) + + let codable = book.toCodable() + return Response(with: codable) + } catch { + print(String(reflecting: error)) + throw error + } } @Sendable private func deleteBook(_ request: Request, context: BasicRequestContext) async throws -> Response { - let bookId = try context.parameters.require("bookId", as: UUID.self) - try await service.deleteBook(with: bookId) - return Response(with: nil) + do { + let bookId = try context.parameters.require("bookId", as: UUID.self) + try await service.deleteBook(with: bookId) + return Response(with: nil) + } catch { + print(String(reflecting: error)) + throw error + } } @Sendable private func deleteAllBooks(_ request: Request, context: BasicRequestContext) async throws -> Response { - try await service.deleteAllBooks() - return Response(with: nil) + do { + try await service.deleteAllBooks() + return Response(with: nil) + } catch { + print(String(reflecting: error)) + throw error + } } } @@ -85,7 +109,7 @@ extension BookController { let publisher: String private let releaseDateString: String var releaseDate: Date { - releaseDateString.date(with: .yearMontDay)! + releaseDateString.date(with: .yearMonthDay)! } enum CodingKeys: String, CodingKey { case title @@ -107,7 +131,7 @@ extension BookController { self.title = title self.author = author self.publisher = publisher - self.releaseDate = releaseDate.string(with: .yearMontDay) + self.releaseDate = releaseDate.string(with: .yearMonthDay) } } } diff --git a/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift b/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift index 14ec204..11ecf16 100644 --- a/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift +++ b/bookstore-hummingbird/Sources/Service/Migrations/CreateBookModels.swift @@ -16,7 +16,7 @@ struct CreateBookModels: AsyncMigration { .field(BookFieldKeys.title, .string, .required) .field(BookFieldKeys.author, .string, .required) .field(BookFieldKeys.publisher, .string, .required) - .field(BookFieldKeys.releaseDate, .date, .required) + .field(BookFieldKeys.releaseDate, .datetime, .required) .create() } From ac1857d801e67f5f18feef026f5448d17c1b3333 Mon Sep 17 00:00:00 2001 From: Moritz Ellerbrock Date: Fri, 25 Oct 2024 07:39:36 +0200 Subject: [PATCH 9/9] add test --- .gitignore | 1 + bookstore-hummingbird/README.md | 0 .../Sources/Service/Models/BookModel.swift | 13 ++- .../DomainTests/BookControllerTests.swift | 1 - .../Helper/MockFactory+Books.swift | 80 +++++++++++++++++++ .../DomainTests/Helper/MockFactory.swift | 14 ++++ 6 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 bookstore-hummingbird/README.md create mode 100644 bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory+Books.swift create mode 100644 bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory.swift diff --git a/.gitignore b/.gitignore index 6858261..1a0203e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ bookstore-rocketrs/migration/target # Swift - Hummingbird bookstore-hummingbird/Packages +postgres diff --git a/bookstore-hummingbird/README.md b/bookstore-hummingbird/README.md new file mode 100644 index 0000000..e69de29 diff --git a/bookstore-hummingbird/Sources/Service/Models/BookModel.swift b/bookstore-hummingbird/Sources/Service/Models/BookModel.swift index 75a6fbd..785fd2e 100644 --- a/bookstore-hummingbird/Sources/Service/Models/BookModel.swift +++ b/bookstore-hummingbird/Sources/Service/Models/BookModel.swift @@ -39,6 +39,17 @@ final class BookModel: Model { self.title = title self.author = author self.publisher = publisher - self.releaseDate = releaseDate + self.releaseDate = releaseDate.onlyDate! + } +} + +extension Date { + var onlyDate: Date? { + get { + let calender = Calendar.current + var dateComponents = calender.dateComponents([.year, .month, .day], from: self) + dateComponents.timeZone = NSTimeZone.system + return calender.date(from: dateComponents) + } } } diff --git a/bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift b/bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift index 76bf42a..465eb0d 100644 --- a/bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift +++ b/bookstore-hummingbird/Tests/DomainTests/BookControllerTests.swift @@ -6,4 +6,3 @@ // import Foundation - diff --git a/bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory+Books.swift b/bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory+Books.swift new file mode 100644 index 0000000..8f601f4 --- /dev/null +++ b/bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory+Books.swift @@ -0,0 +1,80 @@ +// +// File.swift +// bookstore-hummingbird +// +// Created by Moritz Ellerbrock on 04.08.24. +// + +import Foundation + +extension MockFactory { + enum Books { + @discardableResult + static func create(userId: UUID, + permission: PermissionLevel, + client: some TestClientProtocol) async throws -> QuickLinkPartResponse + { + try await client.execute(uri: "/users/\(userId.uuidString)/quicklinks/\(permission.path)", method: .post) { response in + guard response.status == .created else { + Self.logger.error("ERROR-CODE: \(response.status)") + throw Factory.GeneralError.statusCodeMismatch(response.status.code) + } + return try JSONDecoder().decode(QuickLinkPartResponse.self, from: response.body) + } + } + + @discardableResult + static func getList(userId: UUID, + client: some TestClientProtocol) async throws -> QuickLinkListResponse + { + try await client.execute(uri: "/users/\(userId.uuidString)/quicklinks", method: .get) { response in + guard response.status == .ok else { + Self.logger.error("ERROR-CODE: \(response.status)") + throw Factory.GeneralError.statusCodeMismatch(response.status.code) + } + return try JSONDecoder().decode(QuickLinkListResponse.self, from: response.body) + } + } + + @discardableResult + static func get(userId: UUID, + linkPart: String, + client: some TestClientProtocol) async throws -> QuickLinkInfoResponse + { + try await client.execute(uri: "/users/\(userId)/quicklinks/\(linkPart)", method: .get) { response in + guard response.status == .ok else { + Self.logger.error("ERROR-CODE: \(response.status)") + throw Factory.GeneralError.statusCodeMismatch(response.status.code) + } + return try JSONDecoder().decode(QuickLinkInfoResponse.self, from: response.body) + } + } + + @discardableResult + static func update(userId: UUID, + linkPart: String, + permission: PermissionLevel, + client: some TestClientProtocol) async throws -> HTTPResponse.Status + { + try await client.execute(uri: "/users/\(userId)/quicklinks/\(linkPart)/\(permission.path)", method: .patch) { response in + response.status + } + } + + @discardableResult + static func post(linkPart: String, + url: String, + client: some TestClientProtocol) async throws -> WebsiteResponse + { + let request = QuickLinkPostRequest(url: url) + let buffer = try JSONEncoder().encodeAsByteBuffer(request, allocator: ByteBufferAllocator()) + return try await client.execute(uri: "/quicklink/\(linkPart)", method: .post, body: buffer) { response in + guard response.status == .created else { + Self.logger.error("ERROR-CODE: \(response.status)") + throw Factory.GeneralError.statusCodeMismatch(response.status.code) + } + return try JSONDecoder().decode(WebsiteResponse.self, from: response.body) + } + } + } +} diff --git a/bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory.swift b/bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory.swift new file mode 100644 index 0000000..89c384a --- /dev/null +++ b/bookstore-hummingbird/Tests/DomainTests/Helper/MockFactory.swift @@ -0,0 +1,14 @@ +// +// File.swift +// bookstore-hummingbird +// +// Created by Moritz Ellerbrock on 04.08.24. +// + +import Foundation + +enum MockFactory { + enum GeneralError: Error { + case statusCodeMismatch(Int) + } +}