Skip to content

Commit

Permalink
Update s3-upload example for HB 2.0 (#75)
Browse files Browse the repository at this point in the history
* Update S3 upload for Hummingbird 2.0

* Update README

* Update upload-s3/Package.swift

Co-authored-by: Joannis Orlandos <[email protected]>

* Updates from review

---------

Co-authored-by: Joannis Orlandos <[email protected]>
  • Loading branch information
adam-fowler and Joannis authored May 6, 2024
1 parent 9eeceaf commit 8689ee8
Show file tree
Hide file tree
Showing 8 changed files with 163 additions and 187 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Examples converted to Hummingbird 2.0
- [todos-mongokitten-openapi](https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-mongokitten-openapi) - Todos application, using MongoDB driver [MongoKitten](https://github.com/orlandos-nl/MongoKitten) and the [OpenAPI runtime](https://github.com/apple/swift-openapi-runtime).
- [todos-postgres-tutorial](https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-postgres-tutorial) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using PostgresNIO. Sample code that goes along with the [Todos tutorial](https://hummingbird-project.github.io/hummingbird-docs/2.0/tutorials/todos).
- [upload](https://github.com/hummingbird-project/hummingbird-examples/tree/main/upload) - File uploading and downloading.
- [upload-s3](https://github.com/hummingbird-project/hummingbird-examples/tree/main/upload-s3) - File uploading and downloading using AWS S3 as backing store.
- [webauthn](https://github.com/hummingbird-project/hummingbird-examples/tree/main/webauthn) - Web app demonstrating WebAuthn(PassKey) authentication.
- [websocket-chat](https://github.com/hummingbird-project/hummingbird-examples/tree/main/websocket-chat) - Simple chat application using WebSockets.
- [websocket-echo](https://github.com/hummingbird-project/hummingbird-examples/tree/main/websocket-echo) - Simple WebSocket based echo server.
Expand All @@ -30,6 +31,5 @@ Examples still working with Hummingbird 1.0
- [auth-srp](https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x/auth-srp) - Secure Remote Password authentication.
- [ios-image-server](https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x/ios-image-server) - iOS web server that provides access to iPhone photo library.
- [todos-fluent](https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x/todos-fluent) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using Fluent
- [upload-s3](https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x/upload-s3) - File uploading and downloading using AWS S3 as backing store.

The full set of Hummingbird 1.0 examples can be found at https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x
13 changes: 6 additions & 7 deletions upload-s3/Package.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
// swift-tools-version:5.5
// swift-tools-version:5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "upload-s3",
platforms: [.macOS("12.0")],
platforms: [.macOS(.v14)],
products: [
.executable(name: "App", targets: ["App"]),
],
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "1.0.0"),
.package(url: "https://github.com/soto-project/soto.git", from: "6.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"),
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0-beta"),
.package(url: "https://github.com/soto-project/soto.git", from: "7.0.0-beta"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "HummingbirdFoundation", package: "hummingbird"),
.product(name: "SotoS3", package: "soto"),
],
swiftSettings: [
Expand All @@ -34,7 +33,7 @@ let package = Package(
name: "AppTests",
dependencies: [
.byName(name: "App"),
.product(name: "HummingbirdXCT", package: "hummingbird"),
.product(name: "HummingbirdTesting", package: "hummingbird"),
]
),
]
Expand Down
26 changes: 15 additions & 11 deletions upload-s3/Sources/App/App.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import ArgumentParser
import Hummingbird
import Logging

@main
struct App: ParsableCommand, AppArguments {
struct App: AsyncParsableCommand, AppArguments {
@Option(name: .shortAndLong)
var hostname: String = "127.0.0.1"

@Option(name: .shortAndLong)
var port: Int = 8080

func run() throws {
let app = HBApplication(
configuration: .init(
address: .hostname(self.hostname, port: self.port),
serverName: "Hummingbird"
)
)
try app.configure(self)
try app.start()
app.wait()
@Option(name: .shortAndLong)
var logLevel: Logger.Level?

func run() async throws {
let app = buildApplication(self)
try await app.runService()
}
}

/// Extend `Logger.Level` so it can be used as an argument
#if compiler(>=6.0)
extension Logger.Level: @retroactive ExpressibleByArgument {}
#else
extension Logger.Level: ExpressibleByArgument {}
#endif
62 changes: 62 additions & 0 deletions upload-s3/Sources/App/Application+build.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import Hummingbird
import Logging
import ServiceLifecycle
import SotoS3

/// Application arguments protocol. We use a protocol so we can call
/// `HBApplication.configure` inside Tests as well as in the App executable.
/// Any variables added here also have to be added to `App` in App.swift and
/// `TestArguments` in AppTest.swift
public protocol AppArguments {
var hostname: String { get }
var port: Int { get }
var logLevel: Logger.Level? { get }
}

struct AWSClientService: Service {
let client: AWSClient

func run() async throws {
// Ignore cancellation error
try? await gracefulShutdown()
try await self.client.shutdown()
}
}

func buildApplication(_ args: some AppArguments) -> some ApplicationProtocol {
let logger = {
var logger = Logger(label: "html-form")
logger.logLevel = args.logLevel ?? .info
return logger
}()
let env = Environment()
guard let bucket = env.get("s3_upload_bucket") else {
preconditionFailure("Requires \"s3_upload_bucket\" environment variable")
}

let awsClient = AWSClient()
let s3 = S3(client: awsClient, region: .euwest1)

let router = Router()
router.middlewares.add(LogRequestsMiddleware(.info))

router.addRoutes(
S3FileController(
s3: s3,
bucket: bucket,
folder: env.get("s3_upload_folder") ?? "hb-upload-s3"
).getRoutes(),
atPath: "files"
)
router.get("/health") { request, context in
return HTTPResponse.Status.ok
}
var app = Application(
router: router,
configuration: .init(address: .hostname(args.hostname, port: args.port)),
logger: logger
)
app.addServices(AWSClientService(client: awsClient))
return app
}
39 changes: 0 additions & 39 deletions upload-s3/Sources/App/Application+configure.swift

This file was deleted.

20 changes: 0 additions & 20 deletions upload-s3/Sources/App/Application+soto.swift

This file was deleted.

89 changes: 32 additions & 57 deletions upload-s3/Sources/App/Controllers/S3FileController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
//===----------------------------------------------------------------------===//

import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import NIOHTTP1
import SotoS3

/// Handles file transfers
Expand All @@ -24,9 +24,10 @@ struct S3FileController {
let bucket: String
let folder: String

func addRoutes(to group: HBRouterGroup) {
group.get(":filename", use: self.download)
group.post("/", options: .streamBody, use: self.upload)
func getRoutes<Context: BaseRequestContext>(context: Context.Type = Context.self) -> RouteCollection<Context> {
return RouteCollection(context: Context.self)
.get(":filename", use: self.download)
.post("/", use: self.upload)
}

// MARK: - Upload
Expand All @@ -35,7 +36,7 @@ struct S3FileController {
/// A good practice might be to extend this
/// in a way that can be stored a persistable database
/// See ``todos-fluent`` for an example of using databases
struct UploadModel: HBResponseCodable {
struct UploadModel: ResponseCodable {
let filename: String
}

Expand All @@ -45,35 +46,24 @@ struct S3FileController {
/// then that name will be used as the file name on disk, otherwise
/// a UUID will be used.
/// - Returns: A JSON encoded ``UploadModel``
private func upload(_ request: HBRequest) async throws -> UploadModel {
guard let stream = request.body.stream else { throw HBHTTPError(.badRequest) }
guard let contentLength: Int = request.headers["content-length"].first.map({ Int($0) }) ?? nil else {
throw HBHTTPError(.badRequest)
@Sendable private func upload(_ request: Request, context: some BaseRequestContext) async throws -> UploadModel {
guard let contentLength: Int = (request.headers[.contentLength].map { Int($0) } ?? nil) else {
throw HTTPError(.badRequest)
}
let filename = fileName(for: request)

request.logger.info(.init(stringLiteral: "Uploading: \(filename), size: \(contentLength)"))
let body: AWSPayload = .stream(size: contentLength) { eventLoop in
return stream.consume(on: eventLoop).map { output in
switch output {
case .byteBuffer(let buffer):
return .byteBuffer(buffer)
case .end:
return .end
}
}
}
context.logger.info(.init(stringLiteral: "Uploading: \(filename), size: \(contentLength)"))
let putObjectRequest = S3.PutObjectRequest(
body: body,
body: .init(asyncSequence: request.body, length: contentLength),
bucket: self.bucket,
contentType: request.headers["content-type"].first,
contentType: request.headers[.contentType],
key: "\(self.folder)/\(filename)"
)
do {
_ = try await self.s3.putObject(putObjectRequest, logger: request.logger, on: request.eventLoop)
_ = try await self.s3.putObject(putObjectRequest, logger: context.logger)
return UploadModel(filename: filename)
} catch {
throw HBHTTPError(.internalServerError)
throw HTTPError(.internalServerError)
}
}

Expand All @@ -85,47 +75,28 @@ struct S3FileController {
/// - Returns: HBResponse of chunked bytes if success
/// Note that this download has no login checks and allows anyone to download
/// by its filename alone.
private func download(request: HBRequest) async throws -> HBResponse {
guard let filename = request.parameters.get("filename", as: String.self) else {
throw HBHTTPError(.badRequest)
@Sendable private func download(request: Request, context: some BaseRequestContext) async throws -> Response {
guard let filename = context.parameters.get("filename") else {
throw HTTPError(.badRequest)
}
// due to the fact that `getObjectStreaming` doesn't return until all data is downloaded we have
// to get headers values via a headObject call first
let key = "\(self.folder)/\(filename)"
let headResponse = try await s3.headObject(
let s3Response = try await self.s3.getObject(
.init(bucket: self.bucket, key: key),
logger: request.logger,
on: request.eventLoop
logger: context.logger
)
var headers = HTTPHeaders()
if let contentLength = headResponse.contentLength {
headers.add(name: "content-length", value: contentLength.description)
}
if let contentType = headResponse.contentType {
headers.add(name: "content-type", value: contentType)
var headers = HTTPFields()
if let contentLength = s3Response.contentLength {
headers[.contentLength] = contentLength.description
}
// create response body streamer
let streamer = HBByteBufferStreamer(
eventLoop: request.eventLoop,
maxSize: 2048 * 1024,
maxStreamingBufferSize: 128 * 1024
)
// run streaming task separate from request. This means we can start passing buffers from S3 back to
// the client immediately
Task {
_ = try await s3.getObjectStreaming(
.init(bucket: self.bucket, key: key),
logger: request.logger,
on: request.eventLoop
) { buffer, _ in
return streamer.feed(buffer: buffer)
}
streamer.feed(.end)
if let contentType = s3Response.contentType {
headers[.contentType] = contentType
}
return HBResponse(
return Response(
status: .ok,
headers: headers,
body: .stream(streamer)
body: .init(asyncSequence: s3Response.body)
)
}
}
Expand All @@ -137,10 +108,14 @@ extension S3FileController {
return UUID().uuidString.appending(ext)
}

private func fileName(for request: HBRequest) -> String {
guard let fileName = request.headers["File-Name"].first else {
private func fileName(for request: Request) -> String {
guard let fileName = request.headers[.fileName] else {
return self.uuidFileName()
}
return fileName
}
}

extension HTTPField.Name {
static var fileName: Self { .init("File-Name")! }
}
Loading

0 comments on commit 8689ee8

Please sign in to comment.