Skip to content

Commit

Permalink
support for non-sendable responses (#6)
Browse files Browse the repository at this point in the history
* support for non-sendable responses

* adjusted github CI
  • Loading branch information
sliemeobn authored Dec 8, 2024
1 parent 5262cb4 commit a14bc5d
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 9 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/api-breakage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: API breaking changes

on:
pull_request:

jobs:
linux:
runs-on: ubuntu-latest
timeout-minutes: 15
container:
image: swift:latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# https://github.com/actions/checkout/issues/766
- name: Mark the workspace as safe
run: git config --global --add safe.directory ${GITHUB_WORKSPACE}
- name: API breaking changes
run: |
swift package diagnose-api-breaking-changes origin/${GITHUB_BASE_REF}
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
timeout-minutes: 15
strategy:
matrix:
image: ["swift:5.10", "swiftlang/swift:nightly-6.0-jammy"]
image: ["swift:5.10", "swift:6.0"]

container:
image: ${{ matrix.image }}
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
Package.resolved
.vscode/
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"),
.package(url: "https://github.com/sliemeobn/elementary.git", .upToNextMajor(from: "0.3.0")),
.package(url: "https://github.com/sliemeobn/elementary.git", from: "0.4.3"),
],
targets: [
.target(
Expand Down
33 changes: 29 additions & 4 deletions Sources/VaporElementary/HTMLResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import Vapor
/// }
/// }
/// }
///
/// NOTE: For non-sendable HTML values, the resulting response body can only be written once.
/// Multiple writes will result in a runtime error.
/// ```
public struct HTMLResponse: Sendable {
// NOTE: The Sendable requirement on Content can probably be removed in Swift 6 using a sending parameter, and some fancy ~Copyable @unchecked Sendable box type.
// We only need to pass the HTML value to the response generator body closure
private let content: any HTML & Sendable
private let value: _SendableAnyHTMLBox

/// The number of bytes to write to the response body at a time.
///
Expand All @@ -45,13 +48,30 @@ public struct HTMLResponse: Sendable {
/// - additionalHeaders: Additional headers to be merged with predefined headers.
/// - content: The `HTML` content to render in the response.
public init(chunkSize: Int = 1024, additionalHeaders: HTTPHeaders = [:], @HTMLBuilder content: () -> some HTML & Sendable) {
self.init(chunkSize: chunkSize, additionalHeaders: additionalHeaders, value: .init(content()))
}

#if compiler(>=6.0)
@available(macOS 15, *)
/// Creates a new HTMLResponse
///
/// - Parameters:
/// - chunkSize: The number of bytes to write to the response body at a time.
/// - additionalHeaders: Additional headers to be merged with predefined headers.
/// - content: The `HTML` content to render in the response.
public init(chunkSize: Int = 1024, additionalHeaders: HTTPHeaders = [:], @HTMLBuilder content: () -> sending some HTML) {
self.init(chunkSize: chunkSize, additionalHeaders: additionalHeaders, value: .init(content()))
}
#endif

init(chunkSize: Int, additionalHeaders: HTTPHeaders = [:], value: _SendableAnyHTMLBox) {
self.chunkSize = chunkSize
if additionalHeaders.contains(name: .contentType) {
self.headers = additionalHeaders
} else {
self.headers.add(contentsOf: additionalHeaders)
}
self.content = content()
self.value = value
}
}

Expand All @@ -60,8 +80,13 @@ extension HTMLResponse: AsyncResponseEncodable {
Response(
status: .ok,
headers: self.headers,
body: .init(asyncStream: { [content, chunkSize] writer in
try await writer.writeHTML(content, chunkSize: chunkSize)
body: .init(asyncStream: { [value, chunkSize] writer in
guard let html = value.tryTake() else {
assertionFailure("Non-sendable HTML value consumed more than once")
request.logger.error("Non-sendable HTML value consumed more than once")
throw Abort(.internalServerError)
}
try await writer.writeHTML(html, chunkSize: chunkSize)
try await writer.write(.end)
})
)
Expand Down
4 changes: 2 additions & 2 deletions Sources/VaporElementary/HTMLResponseBodyWriter.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Elementary
import Vapor

struct HTMLResponseBodyStreamWriter: HTMLStreamWriter {
struct HTMLResponseBodyStreamWriter<Writer: AsyncBodyStreamWriter>: HTMLStreamWriter {
let allocator: ByteBufferAllocator = .init()
var writer: any AsyncBodyStreamWriter
var writer: Writer

mutating func write(_ bytes: ArraySlice<UInt8>) async throws {
try await self.writer.writeBuffer(self.allocator.buffer(bytes: bytes))
Expand Down
51 changes: 51 additions & 0 deletions Tests/VaporElementaryTests/NonSendableHTMLResponseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Elementary
import Vapor
import VaporElementary
import XCTest
import XCTVapor

final class NonSendableHTMLResponseTests: XCTestCase {
var app: Application!

override func setUp() async throws {
self.app = try await Application.make(.testing)
}

override func tearDown() async throws {
try await self.app.asyncShutdown()
}

func testAllowsSendableValuesToBeWrittenTwice() async throws {
self.app.get { req in
let html = HTMLResponse { "Hello" }
_ = try await html.encodeResponse(for: req).body.collect(on: req.eventLoop)
return html
}

let response = try await app.sendRequest(.GET, "/")
XCTAssertEqual(response.status, .ok)
XCTAssertEqual(String(buffer: response.body), #"Hello"#)
}

#if compiler(>=6.0)
func testRespondsWithANonSendable() async throws {
guard #available(macOS 15.0, *) else {
throw XCTSkip("Test requires macOS 15.0")
}
self.app.get { _ in HTMLResponse { div { NonSendableHTML() } } }

let response = try await app.sendRequest(.GET, "/")
XCTAssertEqual(response.status, .ok)
XCTAssertEqual(String(buffer: response.body), #"<div>Hello</div>"#)
}
#endif
}

@available(*, unavailable)
extension NonSendableHTML: Sendable {}

struct NonSendableHTML: HTML {
var content: some HTML {
"Hello"
}
}

0 comments on commit a14bc5d

Please sign in to comment.