Skip to content

Commit a14bc5d

Browse files
authored
support for non-sendable responses (#6)
* support for non-sendable responses * adjusted github CI
1 parent 5262cb4 commit a14bc5d

7 files changed

Lines changed: 108 additions & 9 deletions

File tree

.github/workflows/api-breakage.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: API breaking changes
2+
3+
on:
4+
pull_request:
5+
6+
jobs:
7+
linux:
8+
runs-on: ubuntu-latest
9+
timeout-minutes: 15
10+
container:
11+
image: swift:latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
with:
16+
fetch-depth: 0
17+
# https://github.com/actions/checkout/issues/766
18+
- name: Mark the workspace as safe
19+
run: git config --global --add safe.directory ${GITHUB_WORKSPACE}
20+
- name: API breaking changes
21+
run: |
22+
swift package diagnose-api-breaking-changes origin/${GITHUB_BASE_REF}

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
timeout-minutes: 15
1717
strategy:
1818
matrix:
19-
image: ["swift:5.10", "swiftlang/swift:nightly-6.0-jammy"]
19+
image: ["swift:5.10", "swift:6.0"]
2020

2121
container:
2222
image: ${{ matrix.image }}

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ DerivedData/
66
.swiftpm/configuration/registries.json
77
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
88
.netrc
9-
Package.resolved
9+
Package.resolved
10+
.vscode/

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ let package = Package(
2323
],
2424
dependencies: [
2525
.package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"),
26-
.package(url: "https://github.com/sliemeobn/elementary.git", .upToNextMajor(from: "0.3.0")),
26+
.package(url: "https://github.com/sliemeobn/elementary.git", from: "0.4.3"),
2727
],
2828
targets: [
2929
.target(

Sources/VaporElementary/HTMLResponse.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@ import Vapor
1414
/// }
1515
/// }
1616
/// }
17+
///
18+
/// NOTE: For non-sendable HTML values, the resulting response body can only be written once.
19+
/// Multiple writes will result in a runtime error.
1720
/// ```
1821
public struct HTMLResponse: Sendable {
1922
// 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.
2023
// We only need to pass the HTML value to the response generator body closure
21-
private let content: any HTML & Sendable
24+
private let value: _SendableAnyHTMLBox
2225

2326
/// The number of bytes to write to the response body at a time.
2427
///
@@ -45,13 +48,30 @@ public struct HTMLResponse: Sendable {
4548
/// - additionalHeaders: Additional headers to be merged with predefined headers.
4649
/// - content: The `HTML` content to render in the response.
4750
public init(chunkSize: Int = 1024, additionalHeaders: HTTPHeaders = [:], @HTMLBuilder content: () -> some HTML & Sendable) {
51+
self.init(chunkSize: chunkSize, additionalHeaders: additionalHeaders, value: .init(content()))
52+
}
53+
54+
#if compiler(>=6.0)
55+
@available(macOS 15, *)
56+
/// Creates a new HTMLResponse
57+
///
58+
/// - Parameters:
59+
/// - chunkSize: The number of bytes to write to the response body at a time.
60+
/// - additionalHeaders: Additional headers to be merged with predefined headers.
61+
/// - content: The `HTML` content to render in the response.
62+
public init(chunkSize: Int = 1024, additionalHeaders: HTTPHeaders = [:], @HTMLBuilder content: () -> sending some HTML) {
63+
self.init(chunkSize: chunkSize, additionalHeaders: additionalHeaders, value: .init(content()))
64+
}
65+
#endif
66+
67+
init(chunkSize: Int, additionalHeaders: HTTPHeaders = [:], value: _SendableAnyHTMLBox) {
4868
self.chunkSize = chunkSize
4969
if additionalHeaders.contains(name: .contentType) {
5070
self.headers = additionalHeaders
5171
} else {
5272
self.headers.add(contentsOf: additionalHeaders)
5373
}
54-
self.content = content()
74+
self.value = value
5575
}
5676
}
5777

@@ -60,8 +80,13 @@ extension HTMLResponse: AsyncResponseEncodable {
6080
Response(
6181
status: .ok,
6282
headers: self.headers,
63-
body: .init(asyncStream: { [content, chunkSize] writer in
64-
try await writer.writeHTML(content, chunkSize: chunkSize)
83+
body: .init(asyncStream: { [value, chunkSize] writer in
84+
guard let html = value.tryTake() else {
85+
assertionFailure("Non-sendable HTML value consumed more than once")
86+
request.logger.error("Non-sendable HTML value consumed more than once")
87+
throw Abort(.internalServerError)
88+
}
89+
try await writer.writeHTML(html, chunkSize: chunkSize)
6590
try await writer.write(.end)
6691
})
6792
)

Sources/VaporElementary/HTMLResponseBodyWriter.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import Elementary
22
import Vapor
33

4-
struct HTMLResponseBodyStreamWriter: HTMLStreamWriter {
4+
struct HTMLResponseBodyStreamWriter<Writer: AsyncBodyStreamWriter>: HTMLStreamWriter {
55
let allocator: ByteBufferAllocator = .init()
6-
var writer: any AsyncBodyStreamWriter
6+
var writer: Writer
77

88
mutating func write(_ bytes: ArraySlice<UInt8>) async throws {
99
try await self.writer.writeBuffer(self.allocator.buffer(bytes: bytes))
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Elementary
2+
import Vapor
3+
import VaporElementary
4+
import XCTest
5+
import XCTVapor
6+
7+
final class NonSendableHTMLResponseTests: XCTestCase {
8+
var app: Application!
9+
10+
override func setUp() async throws {
11+
self.app = try await Application.make(.testing)
12+
}
13+
14+
override func tearDown() async throws {
15+
try await self.app.asyncShutdown()
16+
}
17+
18+
func testAllowsSendableValuesToBeWrittenTwice() async throws {
19+
self.app.get { req in
20+
let html = HTMLResponse { "Hello" }
21+
_ = try await html.encodeResponse(for: req).body.collect(on: req.eventLoop)
22+
return html
23+
}
24+
25+
let response = try await app.sendRequest(.GET, "/")
26+
XCTAssertEqual(response.status, .ok)
27+
XCTAssertEqual(String(buffer: response.body), #"Hello"#)
28+
}
29+
30+
#if compiler(>=6.0)
31+
func testRespondsWithANonSendable() async throws {
32+
guard #available(macOS 15.0, *) else {
33+
throw XCTSkip("Test requires macOS 15.0")
34+
}
35+
self.app.get { _ in HTMLResponse { div { NonSendableHTML() } } }
36+
37+
let response = try await app.sendRequest(.GET, "/")
38+
XCTAssertEqual(response.status, .ok)
39+
XCTAssertEqual(String(buffer: response.body), #"<div>Hello</div>"#)
40+
}
41+
#endif
42+
}
43+
44+
@available(*, unavailable)
45+
extension NonSendableHTML: Sendable {}
46+
47+
struct NonSendableHTML: HTML {
48+
var content: some HTML {
49+
"Hello"
50+
}
51+
}

0 commit comments

Comments
 (0)