diff --git a/.github/workflows/publish-vapor-image.yml b/.github/workflows/publish-vapor-image.yml new file mode 100644 index 0000000..72988f0 --- /dev/null +++ b/.github/workflows/publish-vapor-image.yml @@ -0,0 +1,33 @@ +name: Publish Vapor 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-vapor + 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-vapor diff --git a/.gitignore b/.gitignore index 483a1d0..d705c95 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,12 @@ nb-configuration.xml # Fleet .fleet +# Swift +.build/ +.swiftpm/ +.derivedData +DerivedData + # Rust - Rocket target build folder bookstore-rocketrs/target -bookstore-rocketrs/migration/target \ No newline at end of file +bookstore-rocketrs/migration/target diff --git a/README.md b/README.md index cb2397f..2196f5e 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) [Vapor](./bookstore-vapor) ## Contribute diff --git a/bookstore-vapor/.dockerignore b/bookstore-vapor/.dockerignore new file mode 100644 index 0000000..07a3bb3 --- /dev/null +++ b/bookstore-vapor/.dockerignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +.derivedData +DerivedData \ No newline at end of file diff --git a/bookstore-vapor/.gitignore b/bookstore-vapor/.gitignore new file mode 100644 index 0000000..8f1c94f --- /dev/null +++ b/bookstore-vapor/.gitignore @@ -0,0 +1,10 @@ +Packages +.build +xcuserdata +*.xcodeproj +DerivedData/ +.derivedData/ +.DS_Store +db.sqlite +.swiftpm +.env diff --git a/bookstore-vapor/Dockerfile b/bookstore-vapor/Dockerfile new file mode 100644 index 0000000..9f0c41d --- /dev/null +++ b/bookstore-vapor/Dockerfile @@ -0,0 +1,82 @@ +# ================================ +# Build image +# ================================ +FROM swift:5.10-slim 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. +# This creates a cached layer that can be reused +# as long as your Package.swift/Package.resolved +# files do not change. +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 \ + # Workaround for https://github.com/apple/swift/pull/68669 + # This can be removed as soon as 5.9.1 is released, but is harmless if left in. + -Xlinker -u -Xlinker _swift_backtrace_isThunkFunction + +# 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)/Run" ./ + +# 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 \ +# If your app or its dependencies import FoundationNetworking, also install `libcurl4`. + # libcurl4 \ +# If your app or its dependencies import FoundationXML, also install `libxml2`. + # libxml2 \ + && rm -r /var/lib/apt/lists/* + +# Create a vapor user and group with /app as its home directory +RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor + +# Switch to the new home directory +WORKDIR /app + +# Copy built executable and any staged resources from builder +COPY --from=build --chown=vapor:vapor /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 vapor user +USER vapor:vapor + +# Let Docker bind to port 8080 +EXPOSE 8080 + +# Start the Vapor service when the image is run, default to listening on 8080 in production environment +ENTRYPOINT ["./Run"] +CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] diff --git a/bookstore-vapor/Package.resolved b/bookstore-vapor/Package.resolved new file mode 100644 index 0000000..463f70f --- /dev/null +++ b/bookstore-vapor/Package.resolved @@ -0,0 +1,267 @@ +{ + "originHash" : "5774f338d58f9ed20229f16a661a3e0025751fb8a6a059403586af71997fe136", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "fb308ee72f3d4c082a507033f94afa7395963ef3", + "version" : "1.21.0" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "7ece208cd401687641c88367a00e3ea2b04311f1", + "version" : "1.19.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "a31f44ebfbd15a2cc0fda705279676773ac16355", + "version" : "4.14.1" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "dfcbeba27a576c20ff181d496f21ecd45d2c1a71", + "version" : "4.11.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" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "12ee56f25bd3fc4c2d09c2aa16e69de61dc786e8", + "version" : "4.6.0" + } + }, + { + "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" : "e345cbb9cf6052b37b27c0c4f976134fc01dbe15", + "version" : "1.21.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "2a92a7eac411a82fb3a03731be5e76773ebe1b3e", + "version" : "4.9.0" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "f697d3289c628acd241e3b2c7d3ff068adcc52d1", + "version" : "3.31.1" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.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" : "f0525da24dc3c6cbb2b6b338b65042bc91cbc4bb", + "version" : "3.3.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65", + "version" : "1.0.3" + } + }, + { + "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" : "971ba26378ab69c43737ee7ba967a896cb74c0d1", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "e5a216ba89deba84356bad9d4c2eab99071c745b", + "version" : "2.67.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" : "0904bf0feb5122b7e5c3f15db7df0eabe623dd87", + "version" : "1.30.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" : "6cbe0ed2b394f21ab0d46b9f0c50c6be964968ce", + "version" : "1.20.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "d7fe0e731499a8dcce53bf4cbbc812c8e565d3a7", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "ebbe71c89aa1b76a0920277760b12be7a2ec7c70", + "version" : "4.102.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/bookstore-vapor/Package.swift b/bookstore-vapor/Package.swift new file mode 100644 index 0000000..afc9069 --- /dev/null +++ b/bookstore-vapor/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version:5.10 +import PackageDescription + +let package = Package( + name: "bookstore", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", exact: "4.102.0"), + .package(url: "https://github.com/vapor/fluent.git", exact: "4.11.0"), + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", exact: "2.9.2"), + ], + targets: [ + .target( + name: "App", + dependencies: [ + .product(name: "Fluent", package: "fluent"), + .product(name: "Vapor", package: "vapor"), + .target(name: "Repository"), + .target(name: "Common"), + ], + swiftSettings: [ + .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) + ] + ), + .executableTarget(name: "Run", dependencies: [.target(name: "App")]), + .target( + name: "Repository", + dependencies: [ + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + .product(name: "Fluent", package: "fluent"), + .product(name: "Vapor", package: "vapor"), + ] + ), + .target(name: "Common", + path: "Sources/Common"), + // Testing targets + .testTarget(name: "AppTests", dependencies: [ + .target(name: "App"), + .product(name: "XCTVapor", package: "vapor"), + ]), + ] +) diff --git a/bookstore-vapor/Sources/App/Controllers/.gitkeep b/bookstore-vapor/Sources/App/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/bookstore-vapor/Sources/App/Controllers/BookController.swift b/bookstore-vapor/Sources/App/Controllers/BookController.swift new file mode 100644 index 0000000..5c90d60 --- /dev/null +++ b/bookstore-vapor/Sources/App/Controllers/BookController.swift @@ -0,0 +1,44 @@ +import Fluent +import Vapor + +struct BookController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + let books = routes.grouped("books") + books.post(use: createBook) + books.delete(use: deleteAllBooks) + books.get(use: getAllBooks) + + let bookId = books.grouped(":bookId") + bookId.get(use: getBookById) + bookId.delete(use: deleteBookById) + } + + func createBook(req: Request) async throws -> String { + let dto = try req.content.decode(BookContent.self) + return try await req.repositories.books.create(book: dto) + } + + func deleteAllBooks(req: Request) async throws -> Response { + try await req.repositories.books.deleteAll() + return Response(status: .ok) + } + + func getAllBooks(req: Request) async throws -> [BookContent] { + return try await req.repositories.books.list() + } + + func getBookById(req: Request) async throws -> BookContent { + guard let bookId = req.parameters.get("bookId") else { + throw Abort(.badRequest) + } + return try await req.repositories.books.getBook(by: bookId) + } + + func deleteBookById(req: Request) async throws -> Response { + guard let bookId = req.parameters.get("bookId") else { + throw Abort(.badRequest) + } + try await req.repositories.books.delete(bookId: bookId) + return Response(status: .ok) + } +} diff --git a/bookstore-vapor/Sources/App/Migrations/CreateBook.swift b/bookstore-vapor/Sources/App/Migrations/CreateBook.swift new file mode 100644 index 0000000..4a887ab --- /dev/null +++ b/bookstore-vapor/Sources/App/Migrations/CreateBook.swift @@ -0,0 +1,17 @@ +import Fluent + +struct CreateBooks: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(BookModel.schema) + .id() + .field("title", .string, .required) + .field("author", .string, .required) + .field("release_date", .date, .required) + .field("publisher", .string, .required) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema("books").delete() + } +} diff --git a/bookstore-vapor/Sources/App/Models/BookContent.swift b/bookstore-vapor/Sources/App/Models/BookContent.swift new file mode 100644 index 0000000..f2be7ba --- /dev/null +++ b/bookstore-vapor/Sources/App/Models/BookContent.swift @@ -0,0 +1,19 @@ +import Vapor + +struct BookContent: Content { + let id: UUID? + let title: String + let author: String + let releaseDate: Date + let publisher: String +} + +extension BookContent { + func toModel() -> BookModel { + BookModel(id: self.id, + title: self.title, + author: self.author, + releaseDate: self.releaseDate, + publisher: self.publisher) + } +} diff --git a/bookstore-vapor/Sources/App/Models/BookModel.swift b/bookstore-vapor/Sources/App/Models/BookModel.swift new file mode 100644 index 0000000..5c99888 --- /dev/null +++ b/bookstore-vapor/Sources/App/Models/BookModel.swift @@ -0,0 +1,41 @@ +import Fluent +import Vapor + +final class BookModel: Model { + static let schema = "books" + + @ID(key: .id) + var id: UUID? + + @Field(key: "title") + var title: String + + @Field(key: "author") + var author: String + + @Field(key: "release_date") + var releaseDate: Date + + @Field(key: "publisher") + var publisher: String + + init() { } + + init(id: UUID? = nil, title: String, author: String, releaseDate: Date, publisher: String) { + self.id = id + self.title = title + self.author = author + self.releaseDate = releaseDate + self.publisher = publisher + } +} + +extension BookModel { + func toContent() -> BookContent { + BookContent(id: self.id, + title: self.title, + author: self.author, + releaseDate: self.releaseDate, + publisher: self.publisher) + } +} diff --git a/bookstore-vapor/Sources/App/Models/PaginationContent.swift b/bookstore-vapor/Sources/App/Models/PaginationContent.swift new file mode 100644 index 0000000..f72e37f --- /dev/null +++ b/bookstore-vapor/Sources/App/Models/PaginationContent.swift @@ -0,0 +1,5 @@ +import Vapor + +struct PaginationContent: Content { + let limit: Int +} diff --git a/bookstore-vapor/Sources/App/Models/Repository/BookModelRepository.swift b/bookstore-vapor/Sources/App/Models/Repository/BookModelRepository.swift new file mode 100644 index 0000000..b4fc3bc --- /dev/null +++ b/bookstore-vapor/Sources/App/Models/Repository/BookModelRepository.swift @@ -0,0 +1,73 @@ +import Vapor +import Fluent +import Repository + +enum BookModelRepositoryError: Error { + case noBookFound + case idInvalid +} + +protocol BookModelRepository: RepositoryProtocol { + func list() async throws -> [BookContent] + + func create(book: BookContent) async throws -> String + + func getBook(by bookId: String) async throws -> BookContent + + func delete(bookId: String) async throws -> Void + + func deleteAll() async throws -> Void +} + +struct BookModelRepositoryImpl: BookModelRepository { + var req: Request + + init(_ req: Request) { + self.req = req + } + + private func query() -> QueryBuilder { + BookModel.query(on: req.db) + } + + private func query(_ idString: String) throws -> QueryBuilder { + guard let id = UUID(uuidString: idString) else { + throw BookModelRepositoryError.idInvalid + } + return query().filter(\.$id == id) + } + + func list() async throws -> [BookContent] { + let builder: QueryBuilder + if let limit = try? req.query.decode(PaginationContent.self).limit { + builder = query().limit(limit) + } else { + builder = query() + } + + return try await builder.all().map { model in + model.toContent() + } + } + + func create(book: BookContent) async throws -> String { + let bookModel = book.toModel() + try await bookModel.create(on: req.db) + return bookModel.id?.uuidString ?? "" + } + + func getBook(by bookId: String) async throws -> BookContent { + guard let book = try await query(bookId).first() else { + throw BookModelRepositoryError.noBookFound + } + return book.toContent() + } + + func delete(bookId: String) async throws -> Void { + try await query(bookId).delete() + } + + func deleteAll() async throws -> Void { + try await query().delete() + } +} diff --git a/bookstore-vapor/Sources/App/Registration/RepositoryRegistration.swift b/bookstore-vapor/Sources/App/Registration/RepositoryRegistration.swift new file mode 100644 index 0000000..4a58295 --- /dev/null +++ b/bookstore-vapor/Sources/App/Registration/RepositoryRegistration.swift @@ -0,0 +1,23 @@ +import Vapor +import Repository + +extension RepositoryId { + static let book = RepositoryId("book") +} + +enum RepositoryRegistration { + static func bootstrap(for app: Application) async throws { + app.repositories.register(.book) { req in + BookModelRepositoryImpl(req) + } + } +} + +extension RepositoryFactory { + var books: BookModelRepository { + guard let result = make(.book) as? BookModelRepository else { + fatalError("Book repository is not configured") + } + return result + } +} diff --git a/bookstore-vapor/Sources/App/configure.swift b/bookstore-vapor/Sources/App/configure.swift new file mode 100644 index 0000000..2099567 --- /dev/null +++ b/bookstore-vapor/Sources/App/configure.swift @@ -0,0 +1,37 @@ +import Fluent +import Vapor +import Repository + +// configures your application +public func configure(_ app: Application) async throws { + // set port to 3000 + app.http.server.configuration.port = 3000 + + // register repository + try Repository.configure(app) + // add migrations + app.migrations.add(CreateBooks()) + + try await app.autoMigrate() + + try await RepositoryRegistration.bootstrap(for: app) + + setupCustomDateCoder() + + // register routes + try routes(app) +} + +func setupCustomDateCoder() { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(dateFormatter) + ContentConfiguration.global.use(encoder: encoder, for: .json) + + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(dateFormatter) + ContentConfiguration.global.use(decoder: decoder, for: .json) +} diff --git a/bookstore-vapor/Sources/App/routes.swift b/bookstore-vapor/Sources/App/routes.swift new file mode 100644 index 0000000..2a31d1a --- /dev/null +++ b/bookstore-vapor/Sources/App/routes.swift @@ -0,0 +1,6 @@ +import Fluent +import Vapor + +func routes(_ app: Application) throws { + try app.register(collection: BookController()) +} diff --git a/bookstore-vapor/Sources/Common/Secrets.swift b/bookstore-vapor/Sources/Common/Secrets.swift new file mode 100644 index 0000000..7eb85b5 --- /dev/null +++ b/bookstore-vapor/Sources/Common/Secrets.swift @@ -0,0 +1,149 @@ +// +// File.swift +// +// +// Created by Moritz Ellerbrock on 30.06.24. +// + +import Foundation + +public enum MandatorySecretError: Error { + case setupMissing(String) + case decryptionFailed(String) +} + +public enum FeauterSecretError: Error { + case setupMissing(String) + case decryptionFailed(String) +} + +@dynamicMemberLookup +public struct Secrets: Encodable { + // mandatory + public let dbUsername = "DATABASE_USERNAME" + public let dbPassword = "DATABASE_PASSWORD" + public let dbName = "DATABASE_NAME" // newsletter + public let dbPort = "DATABASE_PORT" // 5432 + public let dbHost = "DATABASE_HOST" // db + + // Encodable + private enum CodingKeys: String, CodingKey { + case dbUsername, dbPassword, dbName, dbPort, dbHost + } + + private static let mandatory: [String] = { + [ + "DATABASE_USERNAME", + "DATABASE_PASSWORD", + "DATABASE_NAME", + "DATABASE_PORT", + "DATABASE_HOST", + ] + }() + + private func isMandatory(_ key: String) -> Bool { + Self.mandatory.contains(key) + } + + private func storedValue(_ value: String) -> String? { + if value == dbHost { + return Environment.get(value) ?? "localhost" + } else if value == dbName { + return Environment.get(value) ?? "postgres" + } else if value == dbUsername { + return Environment.get(value) ?? "postgres" + } else if value == dbPassword { + return Environment.get(value) ?? "My unsafe Secret" + } else if value == dbPort { + return Environment.get(value) ?? "5432" + } else { + return Environment.get(value) + } + } + + public static subscript(dynamicMember keyPath: KeyPath) -> T { + let obj = Secrets() + let key = obj[keyPath: keyPath] as! String + let secret = obj.storedValue(key)! + return secret as! T + } + + public static func verifyValue(for keyPath: KeyPath) -> Bool { + let obj = Secrets() + let key = obj[keyPath: keyPath] as! String + return obj.storedValue(key) != nil + } + + public static func verifySetup() throws { + let object = Secrets() + let dict = object.toDict() + for key in dict.values { + if object.isMandatory(key) { + guard let _ = object.storedValue(key) else { + let error = MandatorySecretError.setupMissing(key) + throw error + } + } else { + do { + guard let _ = object.storedValue(key) else { + throw FeauterSecretError.setupMissing(key) + } + } catch { + print(String(reflecting: error)) + } + } + } + } +} + + +enum Environment { + case testing, develop, staging, production + + static func getEnvironment() -> Environment { + environment(from: Self.get("environment")) + } + + private static func environment(from text: String?, fallback: Environment = .develop) -> Environment { + guard let environment = text?.lowercased() else { return fallback } + if environment.hasPrefix("dev") { + return .develop + } else if environment.hasPrefix("stag") { + return .staging + } else if environment.hasPrefix("prod") { + return .production + } else if environment.hasPrefix("test") { + return .production + } else { + return .develop + } + } +} + +extension Environment { + static func get(_ key: String) -> String? { + let env = ProcessInfo.processInfo.environment + return env[key] + } +} + +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-vapor/Sources/Repository/Application+Repository.swift b/bookstore-vapor/Sources/Repository/Application+Repository.swift new file mode 100644 index 0000000..2875c7a --- /dev/null +++ b/bookstore-vapor/Sources/Repository/Application+Repository.swift @@ -0,0 +1,15 @@ +import Vapor + +public extension Application { + + private struct Key: StorageKey { + typealias Value = RepositoryRegistry + } + + var repositories: RepositoryRegistry { + if storage[Key.self] == nil { + storage[Key.self] = .init(self) + } + return storage[Key.self]! + } +} diff --git a/bookstore-vapor/Sources/Repository/Config.swift b/bookstore-vapor/Sources/Repository/Config.swift new file mode 100644 index 0000000..b00e274 --- /dev/null +++ b/bookstore-vapor/Sources/Repository/Config.swift @@ -0,0 +1,55 @@ +import Vapor +import FluentPostgresDriver +import Fluent + +public func configure(_ app: Application) throws { + try DatabaseConfiguration.setup(in: app) +} + +struct DatabaseConfiguration { + enum DatabaseConfigurationError: LocalizedError { + case usernameMissing, passwordMissing, databaseNameMissing + } + + let hostname: String + let port: Int + let username: String + let password: String + let database: String + + init(_ app: Application) throws { + guard let username = Environment.get("DB_USER") else { + throw DatabaseConfigurationError.usernameMissing + } + guard let password = Environment.get("DB_PASSWORD") else { + throw DatabaseConfigurationError.passwordMissing + } + + let databaseName = app.environment == .testing ? "testing" : Environment.get("DB_NAME") + guard let databaseName else { + throw DatabaseConfigurationError.databaseNameMissing + } + + self.port = Environment.get("DB_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber + self.hostname = Environment.get("DB_HOST") ?? "db" + self.username = username + self.password = password + self.database = databaseName + } + + var config: SQLPostgresConfiguration { + let tls: PostgresConnection.Configuration.TLS = .disable + return .init(hostname: hostname, + port: port, + username: username, + password: password, + database: database, + tls: tls) + } + + static func setup(in app: Application) throws { + let dbConfig = try DatabaseConfiguration(app) + let factoryConfig: DatabaseConfigurationFactory = .postgres(configuration: dbConfig.config) + app.databases.use(factoryConfig, as: .psql) + } +} diff --git a/bookstore-vapor/Sources/Repository/RepositoryFactory.swift b/bookstore-vapor/Sources/Repository/RepositoryFactory.swift new file mode 100644 index 0000000..48c2c7b --- /dev/null +++ b/bookstore-vapor/Sources/Repository/RepositoryFactory.swift @@ -0,0 +1,15 @@ +import Vapor + +public struct RepositoryFactory { + private var registry: RepositoryRegistry + private var req: Request + + init(_ req: Request, _ registry: RepositoryRegistry) { + self.req = req + self.registry = registry + } + + public func make(_ id: RepositoryId) -> RepositoryProtocol { + registry.make(id, req) + } +} diff --git a/bookstore-vapor/Sources/Repository/RepositoryId.swift b/bookstore-vapor/Sources/Repository/RepositoryId.swift new file mode 100644 index 0000000..f8b4e92 --- /dev/null +++ b/bookstore-vapor/Sources/Repository/RepositoryId.swift @@ -0,0 +1,10 @@ +import Vapor + +public struct RepositoryId: Hashable, Codable { + + public let string: String + + public init(_ string: String) { + self.string = string + } +} diff --git a/bookstore-vapor/Sources/Repository/RepositoryProtocol.swift b/bookstore-vapor/Sources/Repository/RepositoryProtocol.swift new file mode 100644 index 0000000..22d360d --- /dev/null +++ b/bookstore-vapor/Sources/Repository/RepositoryProtocol.swift @@ -0,0 +1,6 @@ +import Vapor + +public protocol RepositoryProtocol { + var req: Request { get set } + init(_ req: Request) +} diff --git a/bookstore-vapor/Sources/Repository/RepositoryRegistry.swift b/bookstore-vapor/Sources/Repository/RepositoryRegistry.swift new file mode 100644 index 0000000..980bfa0 --- /dev/null +++ b/bookstore-vapor/Sources/Repository/RepositoryRegistry.swift @@ -0,0 +1,27 @@ +import Vapor + +public final class RepositoryRegistry { + + private let app: Application + private var builders: [RepositoryId: ((Request) -> RepositoryProtocol)] + + init(_ app: Application) { + self.app = app + self.builders = [:] + } + + func builder(_ req: Request) -> RepositoryFactory { + .init(req, self) + } + + func make(_ id: RepositoryId, _ req: Request) -> RepositoryProtocol { + guard let builder = builders[id] else { + fatalError("Repository for id `\(id.string)` is not configured.") + } + return builder(req) + } + + public func register(_ id: RepositoryId, _ builder: @escaping (Request) -> RepositoryProtocol) { + builders[id] = builder + } +} diff --git a/bookstore-vapor/Sources/Repository/Request+RepositoryFactory.swift b/bookstore-vapor/Sources/Repository/Request+RepositoryFactory.swift new file mode 100644 index 0000000..d099fec --- /dev/null +++ b/bookstore-vapor/Sources/Repository/Request+RepositoryFactory.swift @@ -0,0 +1,7 @@ +import Vapor + +public extension Request { + var repositories: RepositoryFactory { + application.repositories.builder(self) + } +} diff --git a/bookstore-vapor/Sources/Run/main.swift b/bookstore-vapor/Sources/Run/main.swift new file mode 100644 index 0000000..cf54feb --- /dev/null +++ b/bookstore-vapor/Sources/Run/main.swift @@ -0,0 +1,9 @@ +import App +import Vapor + +var env = try Environment.detect() +try LoggingSystem.bootstrap(from: &env) +let app = Application(env) +defer { app.shutdown() } +try await configure(app) +try app.run() diff --git a/bookstore-vapor/Tests/AppTests/AppTests.swift b/bookstore-vapor/Tests/AppTests/AppTests.swift new file mode 100644 index 0000000..8fbfda7 --- /dev/null +++ b/bookstore-vapor/Tests/AppTests/AppTests.swift @@ -0,0 +1,10 @@ +@testable import App +import XCTVapor + +final class AppTests: XCTestCase { + func testHelloWorld() async throws { + let app = Application(.testing) + defer { app.shutdown() } + try await configure(app) + } +} diff --git a/terraform/ecr_repositories/variables.tf b/terraform/ecr_repositories/variables.tf index 25e2b2f..096fa7e 100644 --- a/terraform/ecr_repositories/variables.tf +++ b/terraform/ecr_repositories/variables.tf @@ -20,6 +20,7 @@ variable "repositories" { "bookstore-springboot", "bookstore-nestjs", "bookstore-actix", + "bookstore-vapor", "bookstore-rocketrs" ] }