├── Sources ├── App │ ├── Controllers │ │ ├── .gitkeep │ │ ├── CollectFileController.swift │ │ └── StreamController.swift │ ├── HTTPHeaders+FileName.swift │ ├── Models │ │ ├── CollectModel.swift │ │ └── StreamModel.swift │ ├── Migrations │ │ ├── CreateCollect.swift │ │ └── CreateStream.swift │ ├── routes.swift │ ├── configure.swift │ └── UploadHelpers.swift └── Run │ └── main.swift ├── .dockerignore ├── Caddyfile ├── .gitignore ├── Tests └── AppTests │ ├── AppTests.swift │ └── ImageTests.swift ├── data └── certbot │ └── conf │ └── options-ssl-nginx.conf ├── Package.swift ├── docker-compose.yml ├── README.markdown └── Dockerfile /Sources/App/Controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .swiftpm/ 3 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | # Change this to your domain name 2 | example.com 3 | reverse_proxy app:8080 4 | -------------------------------------------------------------------------------- /Sources/App/HTTPHeaders+FileName.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension HTTPHeaders { 4 | static let fileName = Name("File-Name") 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | DerivedData/ 6 | .DS_Store 7 | db.sqlite 8 | .swiftpm 9 | Package.resolved 10 | *.pem 11 | data/ 12 | *.env 13 | nginx.conf 14 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | 4 | var env = try Environment.detect() 5 | try LoggingSystem.bootstrap(from: &env) 6 | let app = Application(env) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | try app.run() 10 | -------------------------------------------------------------------------------- /Sources/App/Models/CollectModel.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class CollectModel: Model, Content { 5 | init() { } 6 | 7 | static let schema = "collect" 8 | 9 | @ID(key: .id) 10 | var id: UUID? 11 | 12 | @Field(key: "data") 13 | var data: Data 14 | 15 | init(id: UUID? = nil, data: Data) { 16 | self.id = id 17 | self.data = data 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import XCTVapor 3 | 4 | final class AppTests: XCTestCase { 5 | func testHelloWorld() throws { 6 | let app = Application(.testing) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | 10 | try app.test(.GET, "hello") { res in 11 | XCTAssertEqual(res.status, .ok) 12 | XCTAssertEqual(res.body.string, "Hello, world!") 13 | } 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateCollect.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateCollect: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | return database.schema(CollectModel.schema) 6 | .id() 7 | .field("data", .data, .required) 8 | .create() 9 | } 10 | 11 | func revert(on database: Database) -> EventLoopFuture { 12 | return database.schema(CollectModel.schema).delete() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateStream.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateStream: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | return database.schema(StreamModel.schema) 6 | .id() 7 | .field("fileName", .string, .required) 8 | .create() 9 | } 10 | 11 | func revert(on database: Database) -> EventLoopFuture { 12 | return database.schema(StreamModel.schema).delete() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/AppTests/ImageTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Michael Critz on 4/13/20. 6 | // 7 | 8 | @testable import App 9 | import XCTVapor 10 | 11 | final class ImageTests: XCTestCase { 12 | var app: Application! 13 | 14 | override func setUp() { 15 | app = Application(.testing) 16 | try! configure(app) 17 | } 18 | override func tearDown() { 19 | app.shutdown() 20 | } 21 | 22 | func testImages() throws { 23 | try app.test(.GET, "images") { res in 24 | XCTAssertEqual(res.status, .ok) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/App/Models/StreamModel.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class StreamModel: Model, Content, CustomStringConvertible { 5 | init() { } 6 | 7 | static let schema = "stream" 8 | 9 | @ID(key: .id) 10 | var id: UUID? 11 | 12 | @Field(key: "fileName") 13 | var fileName: String 14 | 15 | public func filePath(for app: Application) -> String { 16 | app.directory.workingDirectory + "Uploads/" + fileName 17 | } 18 | 19 | var description: String { 20 | return fileName 21 | } 22 | 23 | init(id: UUID? = nil, fileName: String) { 24 | self.id = id 25 | self.fileName = fileName 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /data/certbot/conf/options-ssl-nginx.conf: -------------------------------------------------------------------------------- 1 | # This file contains important security parameters. If you modify this file 2 | # manually, Certbot will be unable to automatically provide future security 3 | # updates. Instead, Certbot will print and log an error message with a path to 4 | # the up-to-date file that you will need to refer to when manually updating 5 | # this file. 6 | 7 | ssl_session_cache shared:le_nginx_SSL:10m; 8 | ssl_session_timeout 1440m; 9 | ssl_session_tickets off; 10 | 11 | ssl_protocols TLSv1.2 TLSv1.3; 12 | ssl_prefer_server_ciphers off; 13 | 14 | ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384"; 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "VaporUploads", 6 | platforms: [ 7 | .macOS(.v12) 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/vapor/vapor", from: "4.67.0"), 11 | .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0-rc"), 12 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), 13 | ], 14 | targets: [ 15 | .target(name: "App", dependencies: [ 16 | .product(name: "Fluent", package: "fluent"), 17 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 18 | .product(name: "Vapor", package: "vapor"), 19 | ]), 20 | .target(name: "Run", dependencies: ["App"]), 21 | .testTarget(name: "AppTests", dependencies: [ 22 | .target(name: "App"), 23 | .product(name: "XCTVapor", package: "vapor"), 24 | ]) 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | func routes(_ app: Application) throws { 5 | // MARK: /collect 6 | let collectFileController = CollectFileController() 7 | app.get("collect", use: collectFileController.index) 8 | 9 | /// Using `body: .collect` we can load the request into memory. 10 | /// This is easier than streaming at the expense of using much more system memory. 11 | app.on(.POST, "collect", 12 | body: .collect(maxSize: 10_000_000), 13 | use: collectFileController.upload) 14 | 15 | // MARK: /stream 16 | let uploadController = StreamController() 17 | /// using `body: .stream` we can get chunks of data from the client, keeping memory use low. 18 | app.on(.POST, "stream", 19 | body: .stream, 20 | use: uploadController.upload) 21 | app.on(.GET, "stream", use: uploadController.index) 22 | app.on(.GET, "stream", ":fileID", use: uploadController.getOne) 23 | app.on(.GET, "stream", ":fileID", "download", use: uploadController.downloadOne) 24 | } 25 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Vapor 4 | 5 | // configures your application 6 | public func configure(_ app: Application) throws { 7 | let logger = Logger(label: "configure") 8 | // uncomment to serve files from /Public folder 9 | // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) 10 | 11 | let port = Environment.get("PORT").flatMap(Int.init) 12 | let dbHost = Environment.get("DATABASE_HOST") ?? "localhost" 13 | let dbDatabase = Environment.get("DATABASE_NAME") ?? "db" 14 | let dbUser = Environment.get("DATABASE_USERNAME") ?? "" 15 | 16 | app.http.server.configuration.port = port ?? 8080 17 | 18 | let dbPassword = Environment.get("DATABASE_PASSWORD") ?? "" 19 | 20 | app.databases.use(.postgres(hostname: dbHost, 21 | username: dbUser, 22 | password: dbPassword, 23 | database: dbDatabase), 24 | as: .psql) 25 | 26 | app.migrations.add(CreateCollect()) 27 | app.migrations.add(CreateStream()) 28 | 29 | try app.autoMigrate().wait() 30 | 31 | let configuredDir = configureUploadDirectory(for: app) 32 | configuredDir.whenFailure { err in 33 | logger.error("Could not create uploads directory \(err.localizedDescription)") 34 | } 35 | configuredDir.whenSuccess { dirPath in 36 | logger.info("created upload directory at \(dirPath)") 37 | } 38 | 39 | // register routes 40 | try routes(app) 41 | } 42 | -------------------------------------------------------------------------------- /Sources/App/Controllers/CollectFileController.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | struct CollectFileController { 5 | let logger = Logger(label: "imagecontroller") 6 | 7 | func index(req: Request) async throws -> [CollectModel] { 8 | return try await CollectModel.query(on: req.db).all() 9 | } 10 | 11 | func upload(req: Request) async throws -> HTTPStatus { 12 | let image = try req.content.decode(CollectModel.self) 13 | try await image.save(on: req.db) 14 | let imageName = image.id?.uuidString ?? "unknown image" 15 | do { 16 | try self.saveFile(name: imageName, data: image.data) 17 | return HTTPStatus.ok 18 | } catch { 19 | logger.critical("failed to save file \(error.localizedDescription)") 20 | return HTTPStatus.internalServerError 21 | } 22 | } 23 | } 24 | 25 | extension CollectFileController { 26 | fileprivate func saveFile(name: String, data: Data) throws { 27 | let path = FileManager.default 28 | .currentDirectoryPath.appending("/\(name)") 29 | if FileManager.default.createFile(atPath: path, 30 | contents: data, 31 | attributes: nil) { 32 | logger.info("saved file\n\t \(path)") 33 | } else { 34 | logger.critical("failed to save file for image \(name)") 35 | throw FileError.couldNotSave(reason: "error writing file \(path)") 36 | } 37 | } 38 | } 39 | 40 | enum FileError: Error { 41 | case couldNotSave(reason: String) 42 | } 43 | -------------------------------------------------------------------------------- /Sources/App/UploadHelpers.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | // MARK: Helpers for naming files 4 | 5 | /// Intended entry point for naming files 6 | /// - Parameter headers: Source `HTTPHeaders` 7 | /// - Returns: `String` with best guess file name. 8 | func filename(with headers: HTTPHeaders) -> String { 9 | let fileNameHeader = headers["File-Name"] 10 | if let inferredName = fileNameHeader.first { 11 | return inferredName 12 | } 13 | 14 | let fileExt = fileExtension(for: headers) 15 | return "upload-\(UUID().uuidString).\(fileExt)" 16 | } 17 | 18 | 19 | /// Parse the header’s Content-Type to determine the file extension 20 | /// - Parameter headers: source `HTTPHeaders` 21 | /// - Returns: `String` guess at appropriate file extension 22 | func fileExtension(for headers: HTTPHeaders) -> String { 23 | var fileExtension = "tmp" 24 | if let contentType = headers.contentType { 25 | switch contentType { 26 | case .jpeg: 27 | fileExtension = "jpg" 28 | case .mp3: 29 | fileExtension = "mp3" 30 | case .init(type: "video", subType: "mp4"): 31 | fileExtension = "mp4" 32 | default: 33 | fileExtension = "bits" 34 | } 35 | } 36 | return fileExtension 37 | } 38 | 39 | /// Creates the upload directory as part of the working directory 40 | /// - Parameters: 41 | /// - directoryName: sub-directory name 42 | /// - app: Application 43 | /// - Returns: name of the directory 44 | func configureUploadDirectory(named directoryName: String = "Uploads/", for app: Application) -> EventLoopFuture { 45 | let createdDirectory = app.eventLoopGroup.next().makePromise(of: String.self) 46 | var uploadDirectoryName = app.directory.workingDirectory 47 | if directoryName.last != "/" { 48 | uploadDirectoryName += "/" 49 | } 50 | uploadDirectoryName += directoryName 51 | do { 52 | try FileManager.default.createDirectory(atPath: uploadDirectoryName, 53 | withIntermediateDirectories: true, 54 | attributes: nil) 55 | createdDirectory.succeed(uploadDirectoryName) 56 | } catch { 57 | createdDirectory.fail(FileError.couldNotSave(reason: error.localizedDescription)) 58 | } 59 | return createdDirectory.futureResult 60 | } 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for Vapor 2 | # 3 | # Install Docker on your system to run and test 4 | # your Vapor app in a production-like environment. 5 | # 6 | # Note: This file is intended for testing and does not 7 | # implement best practices for a production deployment. 8 | # 9 | # Learn more: https://docs.docker.com/compose/reference/ 10 | # 11 | # Build images: docker-compose build 12 | # Start app: docker-compose up app 13 | # Start database: docker-compose up db 14 | # Run migrations: docker-compose run migrate 15 | # Stop all: docker-compose down (add -v to wipe db) 16 | # 17 | version: '3.7' 18 | 19 | volumes: 20 | db_data: 21 | app_data: 22 | caddy_data: 23 | external: true 24 | caddy_config: 25 | 26 | x-shared_environment: &shared_environment 27 | LOG_LEVEL: ${LOG_LEVEL:-debug} 28 | DATABASE_HOST: db 29 | DATABASE_NAME: vapor_database 30 | DATABASE_USERNAME: vapor_username 31 | DATABASE_PASSWORD: vapor_password 32 | 33 | services: 34 | caddy: 35 | image: caddy:2.6 36 | restart: unless-stopped 37 | ports: 38 | - "80:80" 39 | - "443:443" 40 | - "443:443/udp" 41 | volumes: 42 | - $PWD/Caddyfile:/etc/caddy/Caddyfile 43 | - $PWD/site:/srv 44 | - caddy_data:/data 45 | - caddy_config:/config 46 | app: 47 | image: vapor-uploads:latest 48 | build: 49 | context: . 50 | environment: 51 | <<: *shared_environment 52 | volumes: 53 | - app_data:/run 54 | depends_on: 55 | - db 56 | ports: 57 | - '8080:8080' 58 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. 59 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 60 | migrate: 61 | image: vapor-uploads:latest 62 | build: 63 | context: . 64 | environment: 65 | <<: *shared_environment 66 | depends_on: 67 | - db 68 | command: ["migrate", "--yes"] 69 | deploy: 70 | replicas: 0 71 | revert: 72 | image: vapor-uploads:latest 73 | build: 74 | context: . 75 | environment: 76 | <<: *shared_environment 77 | depends_on: 78 | - db 79 | command: ["migrate", "--revert", "--yes"] 80 | deploy: 81 | replicas: 0 82 | db: 83 | image: postgres:14-alpine 84 | volumes: 85 | - db_data:/var/lib/postgresql/data/pgdata 86 | environment: 87 | PGDATA: /var/lib/postgresql/data/pgdata 88 | POSTGRES_USER: vapor_username 89 | POSTGRES_PASSWORD: vapor_password 90 | POSTGRES_DB: vapor_database 91 | ports: 92 | - '5432:5432' 93 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # VaporUploads 2 | 3 | ## Demonstrating File Uploads in Vapor 4 using async / await 4 | 5 | ## Basics 6 | 7 | Files get sent over the internet in different ways. Vapor supports three that I’m aware of. Collecting data in the request, streaming data in a request, and streaming data in WebSockets. 8 | 9 | Vapor provides a handy [File type](https://github.com/vapor/vapor/blob/master/Sources/Vapor/Utilities/File.swift) that can be used in your model to encode useful information like file name. 10 | 11 | ## Collect 12 | 13 | You can upload files on a `POST` and collect all the incoming bytes with `body: .collect(maxSize)` as seen in [routes.swift:11](https://github.com/mcritz/VaporUploads/blob/68d53018f56f0355995a9de20a610a38a57fdec2/Sources/App/routes.swift#L11). 14 | 15 | This is straightforward. Increase the maxSize value and you’ll use that much more system RAM *per active request*. The body will collect common inbound encodings including JSON and form/multipart. 16 | 17 | The obvious downside is that if you’re expecting many requests or large uploads your memory footprint will balloon. Imagine uploading a multigigabyte file into RAM! 18 | 19 | ## HTTP Streaming 20 | 21 | You can also upload files on a `POST` and *stream* the incoming bytes for handling as seen in [routes.swift:18](https://github.com/mcritz/VaporUploads/blob/68d53018f56f0355995a9de20a610a38a57fdec2/Sources/App/routes.swift#L18) and [StreamController.swift:39](https://github.com/mcritz/VaporUploads/blob/68d53018f56f0355995a9de20a610a38a57fdec2/Sources/App/Controllers/StreamController.swift#L39) 22 | 23 | This uses Vapor’s `Request.Body` as an `AsyncSequence`. 24 | 25 | The technical benefit is that the inbound bytes are handled and released from memory, keeping memory usage extremely low: KB instead of MB/GB. You can support many concurrent connections. You can stream very large files. 26 | 27 | ## WebSocket Streaming 28 | 29 | Not yet in this repository, but worth noting for its novelty and performance potential is that you can stream binary data over WebSockets. The strategy is fairly similar to HTTP Streaming because you’ll read inbound bytes (`websocket.onBinary()`) and handle them with `Promise` and `Future` types, but the implementation relies on the WebSocket API. 30 | 31 | So, you need to set up the websocket connection, handle inbound bytes, communicate outcomes to the client, and close the connection when appropriate. If you want, I can provide a proof of concept example. Let me know on twitter: [@mike_critz](https://twitter.com/mike_critz) 32 | 33 | ## Deployment 34 | 35 | The included docker-compose.yml file uses Caddy. You'll need to create the caddy_data volume using `docker volume create caddy_data`. 36 | 37 | Then build the services with `docker compose up --build -d`. 38 | 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:5.7-focal as build 5 | 6 | # Install OS updates and, if needed, sqlite3 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Set up a build area 13 | WORKDIR /build 14 | 15 | # First just resolve dependencies. 16 | # This creates a cached layer that can be reused 17 | # as long as your Package.swift/Package.resolved 18 | # files do not change. 19 | COPY ./Package.* ./ 20 | RUN swift package resolve 21 | 22 | # Copy entire repo into container 23 | COPY . . 24 | 25 | # Build everything, with optimizations 26 | RUN swift build -c release --static-swift-stdlib 27 | 28 | # Switch to the staging area 29 | WORKDIR /staging 30 | 31 | # Copy main executable to staging area 32 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./ 33 | 34 | # Copy resources bundled by SPM to staging area 35 | RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; 36 | 37 | # Copy any resources from the public directory and views directory if the directories exist 38 | # Ensure that by default, neither the directory nor any of its contents are writable. 39 | RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 40 | RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 41 | 42 | # ================================ 43 | # Run image 44 | # ================================ 45 | FROM ubuntu:focal 46 | 47 | # Make sure all system packages are up to date, and install only essential packages. 48 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 49 | && apt-get -q update \ 50 | && apt-get -q dist-upgrade -y \ 51 | && apt-get -q install -y \ 52 | ca-certificates \ 53 | tzdata \ 54 | # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. 55 | # libcurl4 \ 56 | # If your app or its dependencies import FoundationXML, also install `libxml2`. 57 | # libxml2 \ 58 | && rm -r /var/lib/apt/lists/* 59 | 60 | # Create a vapor user and group with /app as its home directory 61 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor 62 | 63 | # Switch to the new home directory 64 | WORKDIR /app 65 | 66 | # Copy built executable and any staged resources from builder 67 | COPY --from=build --chown=vapor:vapor /staging /app 68 | 69 | # Ensure all further commands run as the vapor user 70 | USER vapor:vapor 71 | 72 | # Let Docker bind to port 8080 73 | EXPOSE 8080 74 | 75 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment 76 | ENTRYPOINT ["./Run"] 77 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 78 | -------------------------------------------------------------------------------- /Sources/App/Controllers/StreamController.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | import NIOCore 4 | 5 | struct StreamController { 6 | let logger = Logger(label: "StreamController") 7 | 8 | func index(req: Request) async throws -> [StreamModel] { 9 | try await StreamModel.query(on: req.db).all() 10 | } 11 | 12 | func getOne(req: Request) async throws -> StreamModel { 13 | guard let model = try await StreamModel.find(req.parameters.get("fileID"), on: req.db) else { 14 | throw Abort(.badRequest) 15 | } 16 | return model 17 | } 18 | 19 | /// Streaming download comes with Vapor “out of the box”. 20 | /// Call `req.fileio.streamFile` with a path and Vapor will generate a suitable Response. 21 | func downloadOne(req: Request) async throws -> Response { 22 | let upload = try await getOne(req: req) 23 | return req.fileio.streamFile(at: upload.filePath(for: req.application)) 24 | } 25 | 26 | // MARK: The interesting bit 27 | /// Upload huge files (100s of gigs, even) 28 | /// - Problem 1: If we don’t handle the body as a stream, we’ll end up loading the entire file into memory on request. 29 | /// - Problem 2: Needs to scale for hundreds or thousands of concurrent transfers. So, proper memory management is crucial. 30 | /// - Problem 3: When *streaming* a file over HTTP (as opposed to encoding with multipart form) we need a way to know what the user’s desired filename is. So we handle a custom Header. 31 | /// - Problem 4: Custom headers are sometimes filtered out of network requests, so we need a fallback naming for files. 32 | /** 33 | * Example: 34 | curl --location --request POST 'localhost:8080/fileuploadpath' \ 35 | --header 'Content-Type: video/mp4' \ 36 | --header 'File-Name: bunnies-eating-strawberries.mp4' \ 37 | --data-binary '@/Users/USERNAME/path/to/GiganticMultiGigabyteFile.mp4' 38 | */ 39 | func upload(req: Request) async throws -> some AsyncResponseEncodable { 40 | let logger = Logger(label: "StreamController.upload") 41 | // Create a file on disk based on our `Upload` model. 42 | let fileName = filename(with: req.headers) 43 | let upload = StreamModel(fileName: fileName) 44 | let filePath = upload.filePath(for: req.application) 45 | 46 | // Remove any file with the same name 47 | try? FileManager.default.removeItem(atPath: filePath) 48 | guard FileManager.default.createFile(atPath: filePath, 49 | contents: nil, 50 | attributes: nil) else { 51 | logger.critical("Could not upload \(upload.fileName)") 52 | throw Abort(.internalServerError) 53 | } 54 | let nioFileHandle = try NIOFileHandle(path: filePath, mode: .write) 55 | defer { 56 | do { 57 | try nioFileHandle.close() 58 | } catch { 59 | logger.error("\(error.localizedDescription)") 60 | } 61 | } 62 | do { 63 | var offset: Int64 = 0 64 | for try await byteBuffer in req.body { 65 | do { 66 | try await req.application.fileio.write(fileHandle: nioFileHandle, 67 | toOffset: offset, 68 | buffer: byteBuffer, 69 | eventLoop: req.eventLoop).get() 70 | offset += Int64(byteBuffer.readableBytes) 71 | } catch { 72 | logger.error("\(error.localizedDescription)") 73 | } 74 | } 75 | try await upload.save(on: req.db) 76 | } catch { 77 | try FileManager.default.removeItem(atPath: filePath) 78 | logger.error("File save failed for \(filePath)") 79 | throw Abort(.internalServerError) 80 | } 81 | logger.info("saved \(upload)") 82 | return "Saved \(upload)" 83 | } 84 | } 85 | --------------------------------------------------------------------------------