carlos / todomvc-bakeoff Public
swift: TodoMVC on Hummingbird 2 and SQLiteNIO
Server-rendered list that works with JavaScript off, with a small ES module that sends the same form posts with fetch. Unit tests with Swift Testing; the shared suite passes locally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 files changed,
+2235
−0
apps/swift/.gitignore+6 −0apps/swift/DEPS+3 −0apps/swift/Makefile+75 −0apps/swift/Package.resolved+231 −0apps/swift/Package.swift+30 −0apps/swift/Sources/TodoApp/Assets.swift+692 −0apps/swift/Sources/TodoApp/Page.swift+159 −0apps/swift/Sources/TodoApp/Routes.swift+139 −0apps/swift/Sources/TodoApp/Server.swift+111 −0apps/swift/Sources/TodoApp/Static.swift+14 −0apps/swift/Sources/TodoApp/Store.swift+176 −0apps/swift/Sources/TodoApp/Visitors.swift+77 −0apps/swift/Sources/todo/main.swift+11 −0apps/swift/Tests/TodoAppTests/RouteTests.swift+225 −0apps/swift/Tests/TodoAppTests/StoreTests.swift+120 −0apps/swift/static/app.css+61 −0apps/swift/static/app.js+77 −0apps/swift/tools/embed.sh+28 −0
diff --git a/apps/swift/.gitignore b/apps/swift/.gitignore| new file mode 100644 |
| index 0000000..579c510 |
| --- /dev/null |
| +++ b/apps/swift/.gitignore |
| @@ -0,0 +1,6 @@ |
| +/.cache/ |
| +/dist/ |
| +/Sources/TodoApp/Version.swift |
| +*.db |
| +*.db-shm |
| +*.db-wal |
diff --git a/apps/swift/DEPS b/apps/swift/DEPS| new file mode 100644 |
| index 0000000..7e325d3 |
| --- /dev/null |
| +++ b/apps/swift/DEPS |
| @@ -0,0 +1,3 @@ |
| +github.com/hummingbird-project/hummingbird |
| +github.com/vapor/sqlite-nio |
| +github.com/apple/swift-http-types |
diff --git a/apps/swift/Makefile b/apps/swift/Makefile| new file mode 100644 |
| index 0000000..2aa7e51 |
| --- /dev/null |
| +++ b/apps/swift/Makefile |
| @@ -0,0 +1,75 @@ |
| +# todo-swift: see README.md. |
| +# |
| +# Everything SwiftPM fetches or compiles stays under .cache/ (sources in |
| +# .cache/swiftpm and .cache/build/checkouts, compiled output in |
| +# .cache/build/<triple>), so `make clean-deps` can remove the compiled |
| +# part without touching any other project. |
| + |
| +SHELL := bash |
| +ROOT := $(abspath ../..) |
| +SWIFT := source $(ROOT)/scripts/env.sh && swift |
| +SWIFTPM := --scratch-path .cache/build --cache-path .cache/swiftpm |
| +ARM64_SDK := aarch64-swift-linux-musl |
| +DIRTY := $(shell git status --porcelain -- . 2>/dev/null | grep -q . && echo -dirty) |
| +VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)$(DIRTY) |
| +MSG ?= todo-swift $(VERSION) |
| + |
| +.PHONY: build build-arm64 test clean clean-deps bump unbump deploy generate run |
| + |
| +# Embeds the CSS and JS into the binary and stamps the build version. |
| +# Each file is only rewritten when its content changes. |
| +generate: |
| + @tools/embed.sh $(ROOT)/shared static Sources/TodoApp/Assets.swift |
| + @mkdir -p .cache |
| + @printf '// GENERATED by make. The build version /api/version reports.\npublic let buildVersion = "%s"\n' '$(VERSION)' > .cache/Version.swift.new |
| + @if cmp -s .cache/Version.swift.new Sources/TodoApp/Version.swift; then rm .cache/Version.swift.new; \ |
| + else mv .cache/Version.swift.new Sources/TodoApp/Version.swift; fi |
| + |
| +build: generate |
| + $(SWIFT) build -c release $(SWIFTPM) |
| + @mkdir -p dist |
| + cp .cache/build/release/todo dist/app |
| + |
| +build-arm64: generate |
| + $(SWIFT) build -c release $(SWIFTPM) --swift-sdk $(ARM64_SDK) -Xlinker --strip-all |
| + @mkdir -p dist |
| + cp .cache/build/$(ARM64_SDK)/release/todo dist/app-linux-arm64 |
| + echo '$(VERSION)' > dist/app-linux-arm64.version |
| + @file dist/app-linux-arm64 | grep -q 'ARM aarch64.*statically linked' \ |
| + || { echo "dist/app-linux-arm64 is not a static arm64 binary"; file dist/app-linux-arm64; exit 1; } |
| + |
| +test: generate |
| + $(SWIFT) test $(SWIFTPM) |
| + |
| +# Runs the local build on 127.0.0.1 for the shared suite. |
| +PORT ?= 8080 |
| +run: build |
| + dist/app --listen 127.0.0.1:$(PORT) --db $${TMPDIR:-/tmp}/todo-swift.db |
| + |
| +clean: |
| + rm -rf dist |
| + |
| +# Removes every compiled artifact, ours and the dependencies', and keeps the |
| +# downloaded sources (.cache/swiftpm, .cache/build/checkouts and |
| +# .cache/build/repositories), so the next build compiles everything again. |
| +clean-deps: clean |
| + @[ ! -d .cache/build ] || find .cache/build -mindepth 1 -maxdepth 1 \ |
| + ! -name checkouts ! -name repositories ! -name artifacts ! -name workspace-state.json \ |
| + -exec rm -rf {} + |
| + |
| +bump: |
| + sed -i 's/^let bumpNote = "unbumped"$$/let bumpNote = "bumped"/' Sources/TodoApp/Routes.swift |
| + grep -q '^let bumpNote = "bumped"$$' Sources/TodoApp/Routes.swift |
| + |
| +unbump: |
| + sed -i 's/^let bumpNote = "bumped"$$/let bumpNote = "unbumped"/' Sources/TodoApp/Routes.swift |
| + grep -q '^let bumpNote = "unbumped"$$' Sources/TodoApp/Routes.swift |
| + |
| +# Ships dist/app-linux-arm64. Build it from a clean commit first: the |
| +# version it reports is that commit's short SHA. |
| +deploy: |
| + @[ -f dist/app-linux-arm64 ] || { echo "run make build-arm64 first"; exit 1; } |
| + @case "$(VERSION)" in *-dirty) echo "commit first: the tree has changes"; exit 1;; esac |
| + @[ "$$(cat dist/app-linux-arm64.version)" = '$(VERSION)' ] \ |
| + || { echo "dist/app-linux-arm64 wasn't built at $(VERSION); run make build-arm64"; exit 1; } |
| + carlos deploy --account bfj --app todo-swift --target linux-arm64 --message "$(MSG)" dist/app-linux-arm64 |
diff --git a/apps/swift/Package.resolved b/apps/swift/Package.resolved| new file mode 100644 |
| index 0000000..bbcd8c1 |
| --- /dev/null |
| +++ b/apps/swift/Package.resolved |
| @@ -0,0 +1,231 @@ |
| +{ |
| + "originHash" : "11db42395ee07fa8d0404f7e6b930280fc22baa897020b5c3e7d79f849241b31", |
| + "pins" : [ |
| + { |
| + "identity" : "async-http-client", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/swift-server/async-http-client.git", |
| + "state" : { |
| + "revision" : "4c005f955e83f888d5616e579717a36d2dfc6301", |
| + "version" : "1.36.2" |
| + } |
| + }, |
| + { |
| + "identity" : "hummingbird", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/hummingbird-project/hummingbird.git", |
| + "state" : { |
| + "revision" : "e12178302dacb8226fc78157f89078a59c75dd98", |
| + "version" : "2.28.0" |
| + } |
| + }, |
| + { |
| + "identity" : "sqlite-nio", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/vapor/sqlite-nio.git", |
| + "state" : { |
| + "revision" : "52fd69b9b2ee614d90ba277616fb6cc98ea5a64c", |
| + "version" : "1.13.2" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-algorithms", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-algorithms.git", |
| + "state" : { |
| + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", |
| + "version" : "1.2.1" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-asn1", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-asn1.git", |
| + "state" : { |
| + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", |
| + "version" : "1.7.3" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-async-algorithms", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-async-algorithms.git", |
| + "state" : { |
| + "revision" : "13713a4ffdee8abd929f92568ee9462a46ae26e0", |
| + "version" : "1.1.7" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-atomics", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-atomics.git", |
| + "state" : { |
| + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", |
| + "version" : "1.3.1" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-certificates", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-certificates.git", |
| + "state" : { |
| + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", |
| + "version" : "1.21.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-collections", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-collections.git", |
| + "state" : { |
| + "revision" : "844289ac455816e193f1dbbe014f3c4dbe429410", |
| + "version" : "1.7.4" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-configuration", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-configuration.git", |
| + "state" : { |
| + "revision" : "4e916072eb34f3c136ae64bd2859d6072cdb1b6e", |
| + "version" : "1.3.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-crypto", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-crypto.git", |
| + "state" : { |
| + "revision" : "c2cbadd7263439d8015189668f96ff5b5be565ce", |
| + "version" : "5.0.3" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-distributed-tracing", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-distributed-tracing.git", |
| + "state" : { |
| + "revision" : "297b5b9c82a5631e3199ce3ec0993a3cfb0fbb84", |
| + "version" : "1.7.2" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-http-structured-headers", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-http-structured-headers.git", |
| + "state" : { |
| + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", |
| + "version" : "1.7.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-http-types", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-http-types.git", |
| + "state" : { |
| + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", |
| + "version" : "1.8.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-log", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-log.git", |
| + "state" : { |
| + "revision" : "c9c6bb9d275ec0d95c3ad16391f3f045dc623104", |
| + "version" : "1.16.2" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-metrics", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-metrics.git", |
| + "state" : { |
| + "revision" : "9467f9cc3c32b4386fad1ecc4618328887ef5758", |
| + "version" : "2.12.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-nio", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-nio.git", |
| + "state" : { |
| + "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa", |
| + "version" : "2.104.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-nio-extras", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-nio-extras.git", |
| + "state" : { |
| + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", |
| + "version" : "1.35.1" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-nio-http2", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-nio-http2.git", |
| + "state" : { |
| + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", |
| + "version" : "1.46.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-nio-ssl", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-nio-ssl.git", |
| + "state" : { |
| + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", |
| + "version" : "2.37.5" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-nio-transport-services", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-nio-transport-services.git", |
| + "state" : { |
| + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", |
| + "version" : "1.28.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-numerics", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-numerics.git", |
| + "state" : { |
| + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", |
| + "version" : "1.1.1" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-service-context", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-service-context.git", |
| + "state" : { |
| + "revision" : "853ba3d9ce78851b9cc20108e8bd4f13f7fa546c", |
| + "version" : "1.5.1" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-service-lifecycle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", |
| + "state" : { |
| + "revision" : "a18ee16b8039866fd919bdb4e8e906882d4ecc75", |
| + "version" : "2.13.0" |
| + } |
| + }, |
| + { |
| + "identity" : "swift-system", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/apple/swift-system.git", |
| + "state" : { |
| + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", |
| + "version" : "1.8.1" |
| + } |
| + } |
| + ], |
| + "version" : 3 |
| +} |
diff --git a/apps/swift/Package.swift b/apps/swift/Package.swift| new file mode 100644 |
| index 0000000..53cd5c5 |
| --- /dev/null |
| +++ b/apps/swift/Package.swift |
| @@ -0,0 +1,30 @@ |
| +// swift-tools-version:6.0 |
| +import PackageDescription |
| + |
| +let package = Package( |
| + name: "todo-swift", |
| + platforms: [.macOS(.v14)], |
| + dependencies: [ |
| + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.28.0"), |
| + .package(url: "https://github.com/vapor/sqlite-nio.git", from: "1.13.2"), |
| + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.4.0"), |
| + ], |
| + targets: [ |
| + .target( |
| + name: "TodoApp", |
| + dependencies: [ |
| + .product(name: "Hummingbird", package: "hummingbird"), |
| + .product(name: "SQLiteNIO", package: "sqlite-nio"), |
| + .product(name: "HTTPTypes", package: "swift-http-types"), |
| + ] |
| + ), |
| + .executableTarget(name: "todo", dependencies: ["TodoApp"]), |
| + .testTarget( |
| + name: "TodoAppTests", |
| + dependencies: [ |
| + "TodoApp", |
| + .product(name: "HummingbirdTesting", package: "hummingbird"), |
| + ] |
| + ), |
| + ] |
| +) |
diff --git a/apps/swift/Sources/TodoApp/Assets.swift b/apps/swift/Sources/TodoApp/Assets.swift| new file mode 100644 |
| index 0000000..14ada2e |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Assets.swift |
| @@ -0,0 +1,692 @@ |
| +// GENERATED by tools/embed.sh from shared/ and static/. Do not edit. |
| + |
| +enum Assets { |
| + static let baseCSS = #""" |
| +hr { |
| + margin: 20px 0; |
| + border: 0; |
| + border-top: 1px dashed #c5c5c5; |
| + border-bottom: 1px dashed #f7f7f7; |
| +} |
| + |
| +.learn a { |
| + font-weight: normal; |
| + text-decoration: none; |
| + color: #b83f45; |
| +} |
| + |
| +.learn a:hover { |
| + text-decoration: underline; |
| + color: #787e7e; |
| +} |
| + |
| +.learn h3, |
| +.learn h4, |
| +.learn h5 { |
| + margin: 10px 0; |
| + font-weight: 500; |
| + line-height: 1.2; |
| + color: #000; |
| +} |
| + |
| +.learn h3 { |
| + font-size: 24px; |
| +} |
| + |
| +.learn h4 { |
| + font-size: 18px; |
| +} |
| + |
| +.learn h5 { |
| + margin-bottom: 0; |
| + font-size: 14px; |
| +} |
| + |
| +.learn ul { |
| + padding: 0; |
| + margin: 0 0 30px 25px; |
| +} |
| + |
| +.learn li { |
| + line-height: 20px; |
| +} |
| + |
| +.learn p { |
| + font-size: 15px; |
| + font-weight: 300; |
| + line-height: 1.3; |
| + margin-top: 0; |
| + margin-bottom: 0; |
| +} |
| + |
| +#issue-count { |
| + display: none; |
| +} |
| + |
| +.quote { |
| + border: none; |
| + margin: 20px 0 60px 0; |
| +} |
| + |
| +.quote p { |
| + font-style: italic; |
| +} |
| + |
| +.quote p:before { |
| + content: '“'; |
| + font-size: 50px; |
| + opacity: .15; |
| + position: absolute; |
| + top: -20px; |
| + left: 3px; |
| +} |
| + |
| +.quote p:after { |
| + content: '”'; |
| + font-size: 50px; |
| + opacity: .15; |
| + position: absolute; |
| + bottom: -42px; |
| + right: 3px; |
| +} |
| + |
| +.quote footer { |
| + position: absolute; |
| + bottom: -40px; |
| + right: 0; |
| +} |
| + |
| +.quote footer img { |
| + border-radius: 3px; |
| +} |
| + |
| +.quote footer a { |
| + margin-left: 5px; |
| + vertical-align: middle; |
| +} |
| + |
| +.speech-bubble { |
| + position: relative; |
| + padding: 10px; |
| + background: rgba(0, 0, 0, .04); |
| + border-radius: 5px; |
| +} |
| + |
| +.speech-bubble:after { |
| + content: ''; |
| + position: absolute; |
| + top: 100%; |
| + right: 30px; |
| + border: 13px solid transparent; |
| + border-top-color: rgba(0, 0, 0, .04); |
| +} |
| + |
| +.learn-bar > .learn { |
| + position: absolute; |
| + width: 272px; |
| + top: 8px; |
| + left: -300px; |
| + padding: 10px; |
| + border-radius: 5px; |
| + background-color: rgba(255, 255, 255, .6); |
| + transition-property: left; |
| + transition-duration: 500ms; |
| +} |
| + |
| +@media (min-width: 899px) { |
| + .learn-bar { |
| + width: auto; |
| + padding-left: 300px; |
| + } |
| + |
| + .learn-bar > .learn { |
| + left: 8px; |
| + } |
| +} |
| + |
| +"""# |
| + |
| + static let indexCSS = #""" |
| +@charset 'utf-8'; |
| + |
| +html, |
| +body { |
| + margin: 0; |
| + padding: 0; |
| +} |
| + |
| +button { |
| + margin: 0; |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + font-size: 100%; |
| + vertical-align: baseline; |
| + font-family: inherit; |
| + font-weight: inherit; |
| + color: inherit; |
| + -webkit-appearance: none; |
| + appearance: none; |
| + -webkit-font-smoothing: antialiased; |
| + -moz-osx-font-smoothing: grayscale; |
| +} |
| + |
| +body { |
| + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; |
| + line-height: 1.4em; |
| + background: #f5f5f5; |
| + color: #111111; |
| + min-width: 230px; |
| + max-width: 550px; |
| + margin: 0 auto; |
| + -webkit-font-smoothing: antialiased; |
| + -moz-osx-font-smoothing: grayscale; |
| + font-weight: 300; |
| +} |
| + |
| +.hidden { |
| + display: none; |
| +} |
| + |
| +.todoapp { |
| + background: #fff; |
| + margin: 130px 0 40px 0; |
| + position: relative; |
| + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), |
| + 0 25px 50px 0 rgba(0, 0, 0, 0.1); |
| +} |
| + |
| +.todoapp input::-webkit-input-placeholder { |
| + font-style: italic; |
| + font-weight: 400; |
| + color: rgba(0, 0, 0, 0.4); |
| +} |
| + |
| +.todoapp input::-moz-placeholder { |
| + font-style: italic; |
| + font-weight: 400; |
| + color: rgba(0, 0, 0, 0.4); |
| +} |
| + |
| +.todoapp input::input-placeholder { |
| + font-style: italic; |
| + font-weight: 400; |
| + color: rgba(0, 0, 0, 0.4); |
| +} |
| + |
| +.todoapp h1 { |
| + position: absolute; |
| + top: -140px; |
| + width: 100%; |
| + font-size: 80px; |
| + font-weight: 200; |
| + text-align: center; |
| + color: #b83f45; |
| + -webkit-text-rendering: optimizeLegibility; |
| + -moz-text-rendering: optimizeLegibility; |
| + text-rendering: optimizeLegibility; |
| +} |
| + |
| +.new-todo, |
| +.edit { |
| + position: relative; |
| + margin: 0; |
| + width: 100%; |
| + font-size: 24px; |
| + font-family: inherit; |
| + font-weight: inherit; |
| + line-height: 1.4em; |
| + color: inherit; |
| + padding: 6px; |
| + border: 1px solid #999; |
| + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); |
| + box-sizing: border-box; |
| + -webkit-font-smoothing: antialiased; |
| + -moz-osx-font-smoothing: grayscale; |
| +} |
| + |
| +.new-todo { |
| + padding: 16px 16px 16px 60px; |
| + height: 65px; |
| + border: none; |
| + background: rgba(0, 0, 0, 0.003); |
| + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); |
| +} |
| + |
| +.main { |
| + position: relative; |
| + z-index: 2; |
| + border-top: 1px solid #e6e6e6; |
| +} |
| + |
| +.toggle-all { |
| + width: 1px; |
| + height: 1px; |
| + border: none; /* Mobile Safari */ |
| + opacity: 0; |
| + position: absolute; |
| + right: 100%; |
| + bottom: 100%; |
| +} |
| + |
| +.toggle-all + label { |
| + display: flex; |
| + align-items: center; |
| + justify-content: center; |
| + width: 45px; |
| + height: 65px; |
| + font-size: 0; |
| + position: absolute; |
| + top: -65px; |
| + left: -0; |
| +} |
| + |
| +.toggle-all + label:before { |
| + content: '❯'; |
| + display: inline-block; |
| + font-size: 22px; |
| + color: #949494; |
| + padding: 10px 27px 10px 27px; |
| + -webkit-transform: rotate(90deg); |
| + transform: rotate(90deg); |
| +} |
| + |
| +.toggle-all:checked + label:before { |
| + color: #484848; |
| +} |
| + |
| +.todo-list { |
| + margin: 0; |
| + padding: 0; |
| + list-style: none; |
| +} |
| + |
| +.todo-list li { |
| + position: relative; |
| + font-size: 24px; |
| + border-bottom: 1px solid #ededed; |
| +} |
| + |
| +.todo-list li:last-child { |
| + border-bottom: none; |
| +} |
| + |
| +.todo-list li.editing { |
| + border-bottom: none; |
| + padding: 0; |
| +} |
| + |
| +.todo-list li.editing .edit { |
| + display: block; |
| + width: calc(100% - 43px); |
| + padding: 12px 16px; |
| + margin: 0 0 0 43px; |
| +} |
| + |
| +.todo-list li.editing .view { |
| + display: none; |
| +} |
| + |
| +.todo-list li .toggle { |
| + text-align: center; |
| + width: 40px; |
| + /* auto, since non-WebKit browsers doesn't support input styling */ |
| + height: auto; |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + margin: auto 0; |
| + border: none; /* Mobile Safari */ |
| + -webkit-appearance: none; |
| + appearance: none; |
| +} |
| + |
| +.todo-list li .toggle { |
| + opacity: 0; |
| +} |
| + |
| +.todo-list li .toggle + label { |
| + /* |
| + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 |
| + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ |
| + */ |
| + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); |
| + background-repeat: no-repeat; |
| + background-position: center left; |
| +} |
| + |
| +.todo-list li .toggle:checked + label { |
| + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); |
| +} |
| + |
| +.todo-list li label { |
| + overflow-wrap: break-word; |
| + padding: 15px 15px 15px 60px; |
| + display: block; |
| + line-height: 1.2; |
| + transition: color 0.4s; |
| + font-weight: 400; |
| + color: #484848; |
| +} |
| + |
| +.todo-list li.completed label { |
| + color: #949494; |
| + text-decoration: line-through; |
| +} |
| + |
| +.todo-list li .destroy { |
| + display: none; |
| + position: absolute; |
| + top: 0; |
| + right: 10px; |
| + bottom: 0; |
| + width: 40px; |
| + height: 40px; |
| + margin: auto 0; |
| + font-size: 30px; |
| + color: #949494; |
| + transition: color 0.2s ease-out; |
| +} |
| + |
| +.todo-list li .destroy:hover, |
| +.todo-list li .destroy:focus { |
| + color: #C18585; |
| +} |
| + |
| +.todo-list li .destroy:after { |
| + content: '×'; |
| + display: block; |
| + height: 100%; |
| + line-height: 1.1; |
| +} |
| + |
| +.todo-list li:hover .destroy { |
| + display: block; |
| +} |
| + |
| +.todo-list li .edit { |
| + display: none; |
| +} |
| + |
| +.todo-list li.editing:last-child { |
| + margin-bottom: -1px; |
| +} |
| + |
| +.footer { |
| + padding: 10px 15px; |
| + height: 20px; |
| + text-align: center; |
| + font-size: 15px; |
| + border-top: 1px solid #e6e6e6; |
| +} |
| + |
| +.footer:before { |
| + content: ''; |
| + position: absolute; |
| + right: 0; |
| + bottom: 0; |
| + left: 0; |
| + height: 50px; |
| + overflow: hidden; |
| + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), |
| + 0 8px 0 -3px #f6f6f6, |
| + 0 9px 1px -3px rgba(0, 0, 0, 0.2), |
| + 0 16px 0 -6px #f6f6f6, |
| + 0 17px 2px -6px rgba(0, 0, 0, 0.2); |
| +} |
| + |
| +.todo-count { |
| + float: left; |
| + text-align: left; |
| +} |
| + |
| +.todo-count strong { |
| + font-weight: 300; |
| +} |
| + |
| +.filters { |
| + margin: 0; |
| + padding: 0; |
| + list-style: none; |
| + position: absolute; |
| + right: 0; |
| + left: 0; |
| +} |
| + |
| +.filters li { |
| + display: inline; |
| +} |
| + |
| +.filters li a { |
| + color: inherit; |
| + margin: 3px; |
| + padding: 3px 7px; |
| + text-decoration: none; |
| + border: 1px solid transparent; |
| + border-radius: 3px; |
| +} |
| + |
| +.filters li a:hover { |
| + border-color: #DB7676; |
| +} |
| + |
| +.filters li a.selected { |
| + border-color: #CE4646; |
| +} |
| + |
| +.clear-completed, |
| +html .clear-completed:active { |
| + float: right; |
| + position: relative; |
| + line-height: 19px; |
| + text-decoration: none; |
| + cursor: pointer; |
| +} |
| + |
| +.clear-completed:hover { |
| + text-decoration: underline; |
| +} |
| + |
| +.info { |
| + margin: 65px auto 0; |
| + color: #4d4d4d; |
| + font-size: 11px; |
| + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); |
| + text-align: center; |
| +} |
| + |
| +.info p { |
| + line-height: 1; |
| +} |
| + |
| +.info a { |
| + color: inherit; |
| + text-decoration: none; |
| + font-weight: 400; |
| +} |
| + |
| +.info a:hover { |
| + text-decoration: underline; |
| +} |
| + |
| +/* |
| + Hack to remove background from Mobile Safari. |
| + Can't use it globally since it destroys checkboxes in Firefox |
| +*/ |
| +@media screen and (-webkit-min-device-pixel-ratio:0) { |
| + .toggle-all, |
| + .todo-list li .toggle { |
| + background: none; |
| + } |
| + |
| + .todo-list li .toggle { |
| + height: 40px; |
| + } |
| +} |
| + |
| +@media (max-width: 430px) { |
| + .footer { |
| + height: 50px; |
| + } |
| + |
| + .filters { |
| + bottom: 10px; |
| + } |
| +} |
| + |
| +:focus, |
| +.toggle:focus + label, |
| +.toggle-all:focus + label { |
| + box-shadow: 0 0 2px 2px #CF7D7D; |
| + outline: 0; |
| +} |
| + |
| +"""# |
| + |
| + static let appCSS = #""" |
| +/* The controls a page without JavaScript uses. app.js's inline starter puts |
| + the class "js" on <html>, and then they're hidden: the checkboxes, |
| + double-click and blur take over, exactly as in TodoMVC. */ |
| +.js .nojs { |
| + display: none !important; |
| +} |
| + |
| +/* Forms that only wrap a control take no part in layout. */ |
| +form.inline { |
| + display: contents; |
| +} |
| + |
| +/* Without JavaScript, a submit button lies exactly over each checkbox, so |
| + the page looks the same either way. */ |
| +.todo-list li .view > button[data-action="toggle"] { |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 40px; |
| + height: 40px; |
| + margin: auto 0; |
| + font-size: 0; |
| + cursor: pointer; |
| + z-index: 1; |
| +} |
| + |
| +.main button[data-action="toggle-all"] { |
| + position: absolute; |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| + font-size: 0; |
| + cursor: pointer; |
| + z-index: 1; |
| +} |
| + |
| +/* Without JavaScript there's no double-click, so each item has an edit link |
| + beside the delete button. */ |
| +.todo-list li .view > a[data-action="edit"] { |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + right: 55px; |
| + height: 40px; |
| + margin: auto 0; |
| + font-size: 20px; |
| + line-height: 40px; |
| + color: #949494; |
| + text-decoration: none; |
| +} |
| + |
| +.todo-list li .view > a[data-action="edit"]:hover, |
| +.todo-list li .view > a[data-action="edit"]:focus { |
| + color: #484848; |
| +} |
| + |
| +html:not(.js) .todo-list li label { |
| + padding-right: 90px; |
| +} |
| + |
| +"""# |
| + |
| + static let appJS = #""" |
| +// TodoMVC behaviour on top of the server-rendered page. Every change is |
| +// still the page's own form post; this sends it with fetch and swaps in |
| +// the new list instead of loading the page again. |
| + |
| +const app = document.querySelector('.todoapp'); |
| + |
| +// Sends a form and replaces section.main and footer.footer with what the |
| +// server answers. Anything unexpected falls back to a full page load. |
| +async function send(form) { |
| + if (form.dataset.sending) return; |
| + form.dataset.sending = '1'; |
| + try { |
| + const res = await fetch(form.action, { |
| + method: 'POST', |
| + headers: { 'X-Todo-Partial': '1' }, |
| + body: new URLSearchParams(new FormData(form)), |
| + }); |
| + if (!res.ok) throw new Error(`${res.status}`); |
| + swap(await res.text()); |
| + if (form.closest('.header')) form.reset(); |
| + } catch { |
| + location.reload(); |
| + } finally { |
| + delete form.dataset.sending; |
| + } |
| +} |
| + |
| +function swap(html) { |
| + const tpl = document.createElement('template'); |
| + tpl.innerHTML = html; |
| + app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove()); |
| + app.append(tpl.content); |
| +} |
| + |
| +app.addEventListener('submit', (e) => { |
| + e.preventDefault(); |
| + send(e.target); |
| +}); |
| + |
| +app.addEventListener('change', (e) => { |
| + const li = e.target.closest('li[data-id]'); |
| + if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`)); |
| + if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form); |
| +}); |
| + |
| +// Editing: double-click opens the editor, Enter or blur saves, Escape |
| +// cancels. An empty title deletes the item (the server decides that). |
| +function startEditing(li) { |
| + li.classList.add('editing'); |
| + const input = li.querySelector('.edit'); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +} |
| + |
| +app.addEventListener('dblclick', (e) => { |
| + const li = e.target.closest('.todo-list li'); |
| + if (li && e.target.matches('.view label')) startEditing(li); |
| +}); |
| + |
| +app.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || !e.target.matches('.edit')) return; |
| + const input = e.target; |
| + input.value = input.defaultValue; |
| + input.closest('li').classList.remove('editing'); |
| + input.blur(); |
| +}); |
| + |
| +app.addEventListener('focusout', (e) => { |
| + if (!e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + if (li.classList.contains('editing')) send(e.target.form); |
| +}); |
| + |
| +// The server's edit view (?edit=<id>) opens with that item's editor; put |
| +// the cursor at the end, as double-click does. |
| +const editing = app.querySelector('.todo-list li.editing'); |
| +if (editing) startEditing(editing); |
| + |
| +"""# |
| + |
| +} |
diff --git a/apps/swift/Sources/TodoApp/Page.swift b/apps/swift/Sources/TodoApp/Page.swift| new file mode 100644 |
| index 0000000..91a07e9 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Page.swift |
| @@ -0,0 +1,159 @@ |
| +/// The three views of a list, named as the contract's `filter` field names them. |
| +public enum Filter: String, Sendable, CaseIterable { |
| + case all, active, completed |
| + |
| + /// A missing or unknown value means `all`. |
| + init(field: String?) { |
| + self = field.flatMap(Filter.init(rawValue:)) ?? .all |
| + } |
| + |
| + var path: String { |
| + switch self { |
| + case .all: "/" |
| + case .active: "/active" |
| + case .completed: "/completed" |
| + } |
| + } |
| + |
| + func includes(_ todo: Todo) -> Bool { |
| + switch self { |
| + case .all: true |
| + case .active: !todo.completed |
| + case .completed: todo.completed |
| + } |
| + } |
| +} |
| + |
| +/// Server-rendered HTML. Everything a visitor typed goes through `escape`. |
| +/// |
| +/// The markup follows `shared/reference.html`. The controls marked |
| +/// `class="nojs"` are what a page without JavaScript uses; `app.css` lays the |
| +/// toggle buttons exactly over the checkboxes they stand in for, and hides |
| +/// every `nojs` control once the `js` class is on `<html>`. |
| +enum Page { |
| + static let language = "Swift" |
| + |
| + static func document(todos: [Todo], filter: Filter, editing: Int?, version: String) -> String { |
| + var html = """ |
| + <!doctype html> |
| + <html lang="en"> |
| + <head> |
| + <meta charset="utf-8"> |
| + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| + <title>TodoMVC</title> |
| + <script>document.documentElement.className = "js"</script> |
| + <link rel="stylesheet" href="/static/base.css"> |
| + <link rel="stylesheet" href="/static/index.css"> |
| + <link rel="stylesheet" href="/static/app.css?v=\(escape(version))"> |
| + <script type="module" src="/static/app.js?v=\(escape(version))"></script> |
| + </head> |
| + <body> |
| + <section class="todoapp"> |
| + <header class="header"> |
| + <h1>todos</h1> |
| + <form method="post" action="/todos"> |
| + \(filterField(filter))<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"\(editing == nil ? " autofocus" : "")> |
| + </form> |
| + </header> |
| + |
| + """ |
| + html += body(todos: todos, filter: filter, editing: editing) |
| + html += """ |
| + </section> |
| + <footer class="info"> |
| + <p>Double-click to edit a todo</p> |
| + <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · \(language)</p> |
| + <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| + </footer> |
| + </body> |
| + </html> |
| + |
| + """ |
| + return html |
| + } |
| + |
| + /// `section.main` and `footer.footer`, or nothing when the list is empty. |
| + /// The page's JavaScript swaps this in after each action. |
| + static func body(todos: [Todo], filter: Filter, editing: Int?) -> String { |
| + guard !todos.isEmpty else { return "" } |
| + let left = todos.count { !$0.completed } |
| + let done = todos.count - left |
| + var html = """ |
| + <section class="main"> |
| + <input id="toggle-all" class="toggle-all" type="checkbox"\(left == 0 ? " checked" : "")> |
| + <label for="toggle-all">Mark all as complete</label> |
| + <form class="inline" method="post" action="/todos/toggle-all">\(filterField(filter))<button class="nojs" data-action="toggle-all">Mark all as complete</button></form> |
| + <ul class="todo-list"> |
| + |
| + """ |
| + for todo in todos where filter.includes(todo) { |
| + html += item(todo, filter: filter, editing: todo.id == editing) |
| + } |
| + html += """ |
| + </ul> |
| + </section> |
| + <footer class="footer"> |
| + <span class="todo-count"><strong>\(left)</strong> \(left == 1 ? "item" : "items") left</span> |
| + <ul class="filters"> |
| + |
| + """ |
| + for (f, name) in [(Filter.all, "All"), (.active, "Active"), (.completed, "Completed")] { |
| + html += "<li><a\(f == filter ? " class=\"selected\"" : "") href=\"\(f.path)\">\(name)</a></li>\n" |
| + } |
| + html += "</ul>\n" |
| + if done > 0 { |
| + html += """ |
| + <form class="inline" method="post" action="/todos/clear-completed">\(filterField(filter))<button class="clear-completed">Clear completed</button></form> |
| + |
| + """ |
| + } |
| + html += "</footer>\n" |
| + return html |
| + } |
| + |
| + static func item(_ todo: Todo, filter: Filter, editing: Bool) -> String { |
| + let id = todo.id |
| + let title = escape(todo.title) |
| + var classes: [String] = [] |
| + if todo.completed { classes.append("completed") } |
| + if editing { classes.append("editing") } |
| + let classAttr = classes.isEmpty ? "" : " class=\"\(classes.joined(separator: " "))\"" |
| + let editHref = "\(filter.path)?edit=\(id)" |
| + return """ |
| + <li\(classAttr) data-id="\(id)"> |
| + <form id="toggle-\(id)" method="post" action="/todos/\(id)/toggle" hidden>\(filterField(filter))</form> |
| + <form id="destroy-\(id)" method="post" action="/todos/\(id)/destroy" hidden>\(filterField(filter))</form> |
| + <div class="view"> |
| + <input class="toggle" type="checkbox"\(todo.completed ? " checked" : "") aria-label="Toggle"> |
| + <label>\(title)</label> |
| + <button class="nojs" data-action="toggle" form="toggle-\(id)">Toggle</button> |
| + <a class="nojs" data-action="edit" href="\(editHref)" title="Edit">✎</a> |
| + <button class="destroy" form="destroy-\(id)" aria-label="Delete"></button> |
| + </div> |
| + <form method="post" action="/todos/\(id)/edit">\(filterField(filter))<input class="edit" name="title" value="\(title)" autocomplete="off"\(editing ? " autofocus" : "")></form> |
| + </li> |
| + |
| + """ |
| + } |
| + |
| + static func filterField(_ filter: Filter) -> String { |
| + "<input type=\"hidden\" name=\"filter\" value=\"\(filter.rawValue)\">" |
| + } |
| + |
| + /// Escapes text for use in HTML content and in double-quoted attributes. |
| + static func escape(_ text: String) -> String { |
| + var out = "" |
| + out.reserveCapacity(text.utf8.count) |
| + for c in text.unicodeScalars { |
| + switch c { |
| + case "&": out += "&" |
| + case "<": out += "<" |
| + case ">": out += ">" |
| + case "\"": out += """ |
| + case "'": out += "'" |
| + default: out.unicodeScalars.append(c) |
| + } |
| + } |
| + return out |
| + } |
| +} |
diff --git a/apps/swift/Sources/TodoApp/Routes.swift b/apps/swift/Sources/TodoApp/Routes.swift| new file mode 100644 |
| index 0000000..6557e16 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Routes.swift |
| @@ -0,0 +1,139 @@ |
| +import HTTPTypes |
| +import Hummingbird |
| + |
| +/// `make bump` changes this text so the next build has to recompile this |
| +/// file, the one holding the `GET /` handler; `make unbump` puts it back. |
| +let bumpNote = "unbumped" |
| + |
| +/// The request header the page's JavaScript sends with each action. With |
| +/// it, an action answers 200 with the new `section.main` and |
| +/// `footer.footer` instead of 303. |
| +let partialHeader = HTTPField.Name("X-Todo-Partial")! |
| + |
| +/// Every route the contract lists. |
| +struct Routes: Sendable { |
| + let store: Store |
| + let visitors: Visitors |
| + let version: String |
| + |
| + func register(on router: Router<BasicRequestContext>) { |
| + router.get("/healthz") { _, _ in "ok\n" } |
| + router.get("/api/version") { _, _ in self.version } |
| + |
| + router.get("/static/base.css") { _, _ in Assets.response(Assets.baseCSS, type: "text/css; charset=utf-8") } |
| + router.get("/static/index.css") { _, _ in Assets.response(Assets.indexCSS, type: "text/css; charset=utf-8") } |
| + router.get("/static/app.css") { _, _ in Assets.response(Assets.appCSS, type: "text/css; charset=utf-8") } |
| + router.get("/static/app.js") { _, _ in Assets.response(Assets.appJS, type: "text/javascript; charset=utf-8") } |
| + |
| + for filter in Filter.allCases { |
| + router.get(RouterPath(filter.path)) { request, _ in |
| + try await self.page(request, filter: filter) |
| + } |
| + } |
| + |
| + router.post("/todos") { request, context in |
| + try await self.action(request, context) { list, form in |
| + let title = form.title?.trimmingWhitespace() ?? "" |
| + if !title.isEmpty { try await self.store.add(list: list, title: title) } |
| + return true |
| + } |
| + } |
| + router.post("/todos/toggle-all") { request, context in |
| + try await self.action(request, context) { list, _ in |
| + try await self.store.toggleAll(list: list) |
| + return true |
| + } |
| + } |
| + router.post("/todos/clear-completed") { request, context in |
| + try await self.action(request, context) { list, _ in |
| + try await self.store.clearCompleted(list: list) |
| + return true |
| + } |
| + } |
| + router.post("/todos/:id/toggle") { request, context in |
| + let id = try context.parameters.require("id", as: Int.self) |
| + return try await self.action(request, context) { list, _ in |
| + try await self.store.toggle(list: list, id: id) |
| + } |
| + } |
| + router.post("/todos/:id/edit") { request, context in |
| + let id = try context.parameters.require("id", as: Int.self) |
| + return try await self.action(request, context) { list, form in |
| + let title = form.title?.trimmingWhitespace() ?? "" |
| + return title.isEmpty |
| + ? try await self.store.destroy(list: list, id: id) |
| + : try await self.store.rename(list: list, id: id, title: title) |
| + } |
| + } |
| + router.post("/todos/:id/destroy") { request, context in |
| + let id = try context.parameters.require("id", as: Int.self) |
| + return try await self.action(request, context) { list, _ in |
| + try await self.store.destroy(list: list, id: id) |
| + } |
| + } |
| + } |
| + |
| + /// `GET /`, `/active` and `/completed`, with `?edit=<id>` opening that |
| + /// item's editor. |
| + func page(_ request: Request, filter: Filter) async throws -> Response { |
| + let visitor = try await visitors.resolve(request) |
| + let todos = try await store.todos(list: visitor.list) |
| + let editing = request.uri.queryParameters["edit"].flatMap { Int(String($0)) } |
| + let html = Page.document(todos: todos, filter: filter, editing: editing, version: version) |
| + var response = Response.html(html) |
| + visitors.attach(visitor, to: &response) |
| + return response |
| + } |
| + |
| + struct Form: Decodable { |
| + var title: String? |
| + var filter: String? |
| + } |
| + |
| + /// Runs one form action for the caller's own list. `change` answers |
| + /// false when the item it names isn't in that list, which is a 404. |
| + func action( |
| + _ request: Request, |
| + _ context: BasicRequestContext, |
| + change: (Int, Form) async throws -> Bool |
| + ) async throws -> Response { |
| + var request = request |
| + let body = try await request.collectBody(upTo: context.maxUploadSize) |
| + guard let form = try? URLEncodedFormDecoder().decode(Form.self, from: String(buffer: body)) else { |
| + throw HTTPError(.badRequest, message: "Expected a form.") |
| + } |
| + let filter = Filter(field: form.filter) |
| + let visitor = try await visitors.resolve(request) |
| + |
| + var response: Response |
| + if try await change(visitor.list, form) { |
| + if request.headers[partialHeader] != nil { |
| + let todos = try await store.todos(list: visitor.list) |
| + response = .html(Page.body(todos: todos, filter: filter, editing: nil)) |
| + } else { |
| + response = Response(status: .seeOther, headers: [.location: filter.path]) |
| + } |
| + } else { |
| + response = Response(status: .notFound, headers: [.contentType: "text/plain; charset=utf-8"], |
| + body: .init(byteBuffer: ByteBuffer(string: "No such item in your list.\n"))) |
| + } |
| + visitors.attach(visitor, to: &response) |
| + return response |
| + } |
| +} |
| + |
| +extension Response { |
| + static func html(_ html: String) -> Response { |
| + Response( |
| + status: .ok, |
| + headers: [.contentType: "text/html; charset=utf-8", .cacheControl: "no-store"], |
| + body: .init(byteBuffer: ByteBuffer(string: html)) |
| + ) |
| + } |
| +} |
| + |
| +extension String { |
| + func trimmingWhitespace() -> String { |
| + String(self[...].trimmingWhitespace()) |
| + } |
| +} |
diff --git a/apps/swift/Sources/TodoApp/Server.swift b/apps/swift/Sources/TodoApp/Server.swift| new file mode 100644 |
| index 0000000..88ee930 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Server.swift |
| @@ -0,0 +1,111 @@ |
| +import Foundation |
| +import Hummingbird |
| +import Logging |
| + |
| +/// How the binary was started: `--socket <path> --db <path>` on Carloku, |
| +/// or `--listen 127.0.0.1:<port> --db <path>` for local runs and tests. |
| +public struct Options: Sendable, Equatable { |
| + public enum Address: Sendable, Equatable { |
| + case socket(String) |
| + case listen(host: String, port: Int) |
| + } |
| + |
| + public var address: Address |
| + public var database: String |
| + /// `CARLOS_HOST`, the instance's hostname, if the platform set it. |
| + public var carlosHost: String? |
| + |
| + public struct UsageError: Error, CustomStringConvertible { |
| + public var description: String |
| + } |
| + |
| + public static let usage = "usage: todo (--socket <path> | --listen 127.0.0.1:<port>) --db <path>" |
| + |
| + public init(arguments: [String], environment: [String: String]) throws { |
| + var socket: String? |
| + var listen: String? |
| + var database: String? |
| + var rest = arguments[...] |
| + while let flag = rest.popFirst() { |
| + guard let value = rest.popFirst() else { throw UsageError(description: "\(flag) needs a value\n\(Self.usage)") } |
| + switch flag { |
| + case "--socket", "-socket": socket = value |
| + case "--listen", "-listen": listen = value |
| + case "--db", "-db": database = value |
| + default: throw UsageError(description: "unknown flag \(flag)\n\(Self.usage)") |
| + } |
| + } |
| + guard let database else { throw UsageError(description: Self.usage) } |
| + self.database = database |
| + switch (socket, listen) { |
| + case (let path?, nil): |
| + address = .socket(path) |
| + case (nil, let listen?): |
| + guard let colon = listen.lastIndex(of: ":"), listen[..<colon] == "127.0.0.1", |
| + let port = Int(listen[listen.index(after: colon)...]), (1...65535).contains(port) |
| + else { throw UsageError(description: "--listen takes 127.0.0.1:<port>; nothing else is bound") } |
| + address = .listen(host: "127.0.0.1", port: port) |
| + default: |
| + throw UsageError(description: Self.usage) |
| + } |
| + carlosHost = environment["CARLOS_HOST"].flatMap { $0.isEmpty ? nil : $0 } |
| + } |
| + |
| + /// The origin a browser sends with the app's own form posts. |
| + var origin: String? { |
| + switch address { |
| + case .listen(let host, let port): "http://\(host):\(port)" |
| + case .socket: carlosHost.map { "https://\($0)" } |
| + } |
| + } |
| + |
| + /// On a socket the platform's edge serves the app over https. |
| + var servedOverHTTPS: Bool { |
| + if case .socket = address { return true } |
| + return false |
| + } |
| +} |
| + |
| +public enum Server { |
| + /// Builds the router for a store; the tests drive this directly. |
| + public static func router(store: Store, options: Options, version: String = buildVersion) -> Router<BasicRequestContext> { |
| + let router = Router() |
| + router.add(middleware: OriginCheck(origin: options.origin)) |
| + Routes(store: store, visitors: Visitors(store: store, secure: options.servedOverHTTPS), version: version) |
| + .register(on: router) |
| + return router |
| + } |
| + |
| + /// Serves until SIGTERM or SIGINT, then closes the database and returns. |
| + public static func run(_ options: Options) async throws { |
| + var logger = Logger(label: "todo-swift") |
| + logger.logLevel = .info |
| + let store = try await Store.open(path: options.database) |
| + |
| + let address: BindAddress |
| + switch options.address { |
| + case .socket(let path): |
| + // A socket file left behind by an earlier process would make bind fail. |
| + try? FileManager.default.removeItem(atPath: path) |
| + address = .unixDomainSocket(path: path) |
| + case .listen(let host, let port): |
| + address = .hostname(host, port: port) |
| + } |
| + |
| + let app = Application( |
| + router: router(store: store, options: options), |
| + configuration: .init(address: address), |
| + logger: logger |
| + ) |
| + do { |
| + try await app.runService() |
| + } catch { |
| + try? await store.close() |
| + throw error |
| + } |
| + try await store.close() |
| + if case .socket(let path) = options.address { |
| + try? FileManager.default.removeItem(atPath: path) |
| + } |
| + } |
| +} |
diff --git a/apps/swift/Sources/TodoApp/Static.swift b/apps/swift/Sources/TodoApp/Static.swift| new file mode 100644 |
| index 0000000..95cd0d0 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Static.swift |
| @@ -0,0 +1,14 @@ |
| +import Hummingbird |
| + |
| +extension Assets { |
| + /// One embedded file. The CSS and JS change only with a new build, and |
| + /// the page links app.css and app.js with the build version, so |
| + /// browsers may keep them for a day. |
| + static func response(_ text: String, type: String) -> Response { |
| + Response( |
| + status: .ok, |
| + headers: [.contentType: type, .cacheControl: "public, max-age=86400"], |
| + body: .init(byteBuffer: ByteBuffer(string: text)) |
| + ) |
| + } |
| +} |
diff --git a/apps/swift/Sources/TodoApp/Store.swift b/apps/swift/Sources/TodoApp/Store.swift| new file mode 100644 |
| index 0000000..8659b63 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Store.swift |
| @@ -0,0 +1,176 @@ |
| +import SQLiteNIO |
| + |
| +/// One todo item as the page shows it. |
| +public struct Todo: Sendable, Equatable { |
| + public var id: Int |
| + public var title: String |
| + public var completed: Bool |
| +} |
| + |
| +/// The app's SQLite database: one file, WAL mode, one connection. |
| +/// |
| +/// SQLiteNIO runs every call on its thread pool, and the one connection |
| +/// serialises them. Each write below is a single SQL statement, so two |
| +/// requests can never interleave half a change, and no transaction has to |
| +/// span an `await`. |
| +public final class Store: Sendable { |
| + let db: SQLiteConnection |
| + |
| + /// Schema changes, in order. Only ever append to this list; never edit |
| + /// or remove an entry, and never drop or rewrite data. |
| + /// `PRAGMA user_version` records how many have run. |
| + static let migrations: [String] = [ |
| + """ |
| + CREATE TABLE lists ( |
| + id INTEGER PRIMARY KEY, |
| + token TEXT NOT NULL UNIQUE, |
| + created_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| + ); |
| + CREATE TABLE todos ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + list_id INTEGER NOT NULL REFERENCES lists(id), |
| + title TEXT NOT NULL, |
| + completed INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| + ); |
| + CREATE INDEX todos_by_list ON todos(list_id, id); |
| + """, |
| + ] |
| + |
| + init(db: SQLiteConnection) { |
| + self.db = db |
| + } |
| + |
| + /// Opens (creating if needed) the database at `path` and brings its |
| + /// schema up to date. |
| + public static func open(path: String) async throws -> Store { |
| + let db = try await SQLiteConnection.open(storage: .file(path: path)) |
| + let store = Store(db: db) |
| + do { |
| + try await store.prepare() |
| + } catch { |
| + try? await db.close() |
| + throw error |
| + } |
| + return store |
| + } |
| + |
| + func prepare() async throws { |
| + _ = try await db.query("PRAGMA journal_mode = WAL") |
| + _ = try await db.query("PRAGMA synchronous = NORMAL") |
| + _ = try await db.query("PRAGMA busy_timeout = 5000") |
| + _ = try await db.query("PRAGMA foreign_keys = ON") |
| + let version = try await db.query("PRAGMA user_version").first?.column("user_version")?.integer ?? 0 |
| + for (index, sql) in Self.migrations.enumerated() where index >= version { |
| + // The migration and its version bump commit together, or not at all. |
| + try await db.withConnection { conn in |
| + _ = try await conn.query("BEGIN IMMEDIATE") |
| + do { |
| + for statement in sql.split(separator: ";").map({ $0.trimmingWhitespace() }) where !statement.isEmpty { |
| + _ = try await conn.query(String(statement)) |
| + } |
| + _ = try await conn.query("PRAGMA user_version = \(index + 1)") |
| + _ = try await conn.query("COMMIT") |
| + } catch { |
| + _ = try? await conn.query("ROLLBACK") |
| + throw error |
| + } |
| + } |
| + } |
| + } |
| + |
| + public func close() async throws { |
| + try await db.close() |
| + } |
| + |
| + // MARK: Lists |
| + |
| + /// The list a cookie token names, if the app ever issued it. |
| + public func list(token: String) async throws -> Int? { |
| + try await db.query("SELECT id FROM lists WHERE token = ?", [.text(token)]) |
| + .first?.column("id")?.integer |
| + } |
| + |
| + /// Records a new, empty list under `token` and returns its id. |
| + public func createList(token: String) async throws -> Int { |
| + let rows = try await db.query("INSERT INTO lists (token) VALUES (?) RETURNING id", [.text(token)]) |
| + guard let id = rows.first?.column("id")?.integer else { throw StoreError.noRowReturned } |
| + return id |
| + } |
| + |
| + // MARK: Todos |
| + |
| + public func todos(list: Int) async throws -> [Todo] { |
| + try await db.query( |
| + "SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id", |
| + [.integer(list)] |
| + ).map { row in |
| + Todo( |
| + id: row.column("id")?.integer ?? 0, |
| + title: row.column("title")?.string ?? "", |
| + completed: row.column("completed")?.bool ?? false |
| + ) |
| + } |
| + } |
| + |
| + public func add(list: Int, title: String) async throws { |
| + _ = try await db.query("INSERT INTO todos (list_id, title) VALUES (?, ?)", [.integer(list), .text(title)]) |
| + } |
| + |
| + /// Flips one item. False if the list has no such item. |
| + public func toggle(list: Int, id: Int) async throws -> Bool { |
| + try await changed( |
| + "UPDATE todos SET completed = 1 - completed WHERE id = ? AND list_id = ? RETURNING id", |
| + [.integer(id), .integer(list)] |
| + ) |
| + } |
| + |
| + /// Renames one item. False if the list has no such item. |
| + public func rename(list: Int, id: Int, title: String) async throws -> Bool { |
| + try await changed( |
| + "UPDATE todos SET title = ? WHERE id = ? AND list_id = ? RETURNING id", |
| + [.text(title), .integer(id), .integer(list)] |
| + ) |
| + } |
| + |
| + /// Deletes one item. False if the list has no such item. |
| + public func destroy(list: Int, id: Int) async throws -> Bool { |
| + try await changed( |
| + "DELETE FROM todos WHERE id = ? AND list_id = ? RETURNING id", |
| + [.integer(id), .integer(list)] |
| + ) |
| + } |
| + |
| + /// Completes every item, or, if every item already is, reopens them all. |
| + public func toggleAll(list: Int) async throws { |
| + _ = try await db.query( |
| + """ |
| + UPDATE todos |
| + SET completed = EXISTS (SELECT 1 FROM todos WHERE list_id = ?1 AND completed = 0) |
| + WHERE list_id = ?1 |
| + """, |
| + [.integer(list)] |
| + ) |
| + } |
| + |
| + public func clearCompleted(list: Int) async throws { |
| + _ = try await db.query("DELETE FROM todos WHERE list_id = ? AND completed = 1", [.integer(list)]) |
| + } |
| + |
| + private func changed(_ sql: String, _ binds: [SQLiteData]) async throws -> Bool { |
| + try await !db.query(sql, binds).isEmpty |
| + } |
| +} |
| + |
| +enum StoreError: Error { |
| + case noRowReturned |
| +} |
| + |
| +extension Substring { |
| + func trimmingWhitespace() -> Substring { |
| + var s = self |
| + while let c = s.first, c.isWhitespace { s.removeFirst() } |
| + while let c = s.last, c.isWhitespace { s.removeLast() } |
| + return s |
| + } |
| +} |
diff --git a/apps/swift/Sources/TodoApp/Visitors.swift b/apps/swift/Sources/TodoApp/Visitors.swift| new file mode 100644 |
| index 0000000..15dd475 |
| --- /dev/null |
| +++ b/apps/swift/Sources/TodoApp/Visitors.swift |
| @@ -0,0 +1,77 @@ |
| +import HTTPTypes |
| +import Hummingbird |
| + |
| +/// Gives each visitor their own list through an anonymous cookie. |
| +/// |
| +/// The cookie holds 128 random bits as 32 hex digits. A value the app |
| +/// didn't issue, well-formed or not, gets a fresh empty list and a new |
| +/// cookie, never an error. |
| +struct Visitors: Sendable { |
| + static let cookieName = "todo_list" |
| + |
| + let store: Store |
| + /// Adds `Secure` to the cookie. True when served over https. |
| + let secure: Bool |
| + |
| + struct Visitor { |
| + var list: Int |
| + /// Set when this request minted the visitor a new list. |
| + var newToken: String? |
| + } |
| + |
| + func resolve(_ request: Request) async throws -> Visitor { |
| + if let token = request.cookies[Self.cookieName]?.value, Self.isWellFormed(token), |
| + let list = try await store.list(token: token) |
| + { |
| + return Visitor(list: list) |
| + } |
| + let token = Self.newToken() |
| + return Visitor(list: try await store.createList(token: token), newToken: token) |
| + } |
| + |
| + func attach(_ visitor: Visitor, to response: inout Response) { |
| + guard let token = visitor.newToken else { return } |
| + // About 400 days, the longest lifetime browsers honour. |
| + var cookie = "\(Self.cookieName)=\(token); Path=/; Max-Age=34560000; HttpOnly; SameSite=Lax" |
| + if secure { cookie += "; Secure" } |
| + response.headers.append(HTTPField(name: .setCookie, value: cookie)) |
| + } |
| + |
| + static func newToken() -> String { |
| + var rng = SystemRandomNumberGenerator() |
| + let bytes = (0..<16).map { _ in UInt8.random(in: .min ... .max, using: &rng) } |
| + return bytes.map { byte in |
| + let hex = String(byte, radix: 16) |
| + return byte < 16 ? "0" + hex : hex |
| + }.joined() |
| + } |
| + |
| + static func isWellFormed(_ token: String) -> Bool { |
| + token.utf8.count == 32 && token.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } |
| + } |
| +} |
| + |
| +/// Refuses a POST whose `Origin` header names another site. A POST with no |
| +/// `Origin` is allowed; there is no CSRF token. |
| +struct OriginCheck<Context: RequestContext>: RouterMiddleware { |
| + /// The app's own origin, or nil to accept the origin the `Host` header |
| + /// implies (an instance started without `CARLOS_HOST`). |
| + let origin: String? |
| + |
| + func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { |
| + if request.method == .post, let sent = request.headers[.origin], !allows(sent, host: request.head.authority) { |
| + return Response( |
| + status: .forbidden, |
| + headers: [.contentType: "text/plain; charset=utf-8"], |
| + body: .init(byteBuffer: ByteBuffer(string: "Cross-site form posts are refused.\n")) |
| + ) |
| + } |
| + return try await next(request, context) |
| + } |
| + |
| + func allows(_ sent: String, host: String?) -> Bool { |
| + if let origin { return sent == origin } |
| + guard let host else { return false } |
| + return sent == "https://\(host)" || sent == "http://\(host)" |
| + } |
| +} |
diff --git a/apps/swift/Sources/todo/main.swift b/apps/swift/Sources/todo/main.swift| new file mode 100644 |
| index 0000000..b5cbed6 |
| --- /dev/null |
| +++ b/apps/swift/Sources/todo/main.swift |
| @@ -0,0 +1,11 @@ |
| +import Foundation |
| +import TodoApp |
| + |
| +let options: Options |
| +do { |
| + options = try Options(arguments: Array(CommandLine.arguments.dropFirst()), environment: ProcessInfo.processInfo.environment) |
| +} catch { |
| + FileHandle.standardError.write(Data("\(error)\n".utf8)) |
| + exit(2) |
| +} |
| +try await Server.run(options) |
diff --git a/apps/swift/Tests/TodoAppTests/RouteTests.swift b/apps/swift/Tests/TodoAppTests/RouteTests.swift| new file mode 100644 |
| index 0000000..8196eff |
| --- /dev/null |
| +++ b/apps/swift/Tests/TodoAppTests/RouteTests.swift |
| @@ -0,0 +1,225 @@ |
| +import Foundation |
| +import HTTPTypes |
| +import Hummingbird |
| +import HummingbirdTesting |
| +import Testing |
| +@testable import TodoApp |
| + |
| +let origin = "http://127.0.0.1:8080" |
| + |
| +/// Runs `body` against the app's router on a fresh database. |
| +func withApp(_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void) async throws { |
| + try await withStore { store, _ in |
| + let options = try Options(arguments: ["--listen", "127.0.0.1:8080", "--db", "unused"], environment: [:]) |
| + let app = Application(router: Server.router(store: store, options: options, version: "test-version")) |
| + try await app.test(.router, body) |
| + } |
| +} |
| + |
| +extension TestResponse { |
| + var text: String { body.getString(at: body.readerIndex, length: body.readableBytes) ?? "" } |
| + var listCookie: String? { |
| + guard let header = headers[.setCookie], header.hasPrefix("todo_list=") else { return nil } |
| + return String(header.prefix { $0 != ";" }) |
| + } |
| +} |
| + |
| +extension TestClientProtocol { |
| + /// Visits `/` and returns the cookie for a new, empty list. |
| + func newVisitor() async throws -> String { |
| + try await execute(uri: "/", method: .get) { try #require($0.listCookie) } |
| + } |
| + |
| + func post(_ path: String, _ form: String, cookie: String?, headers: HTTPFields = [:]) async throws -> TestResponse { |
| + var fields = headers |
| + fields[.contentType] = "application/x-www-form-urlencoded" |
| + if let cookie { fields[.cookie] = cookie } |
| + return try await execute(uri: path, method: .post, headers: fields, body: ByteBuffer(string: form)) |
| + } |
| + |
| + func get(_ path: String, cookie: String) async throws -> TestResponse { |
| + try await execute(uri: path, method: .get, headers: [.cookie: cookie]) |
| + } |
| +} |
| + |
| +func ids(_ html: String) -> [String] { |
| + html.split(separator: "data-id=\"").dropFirst().map { String($0.prefix { $0 != "\"" }) } |
| +} |
| + |
| +@Suite struct RouteTests { |
| + @Test func healthAndVersion() async throws { |
| + try await withApp { client in |
| + try await client.execute(uri: "/healthz", method: .get) { #expect($0.status == .ok) } |
| + try await client.execute(uri: "/api/version", method: .get) { |
| + #expect($0.status == .ok) |
| + #expect($0.text == "test-version") |
| + } |
| + } |
| + } |
| + |
| + @Test func firstVisitGetsAnHttpOnlyLaxCookie() async throws { |
| + try await withApp { client in |
| + try await client.execute(uri: "/", method: .get) { response in |
| + #expect(response.status == .ok) |
| + let header = try #require(response.headers[.setCookie]) |
| + let value = header.prefix { $0 != ";" }.dropFirst("todo_list=".count) |
| + #expect(Visitors.isWellFormed(String(value))) |
| + #expect(header.contains("HttpOnly")) |
| + #expect(header.contains("SameSite=Lax")) |
| + #expect(header.contains("Path=/")) |
| + #expect(!header.contains("Secure")) |
| + #expect(!response.text.contains("class=\"main\"")) |
| + #expect(response.text.contains("· Swift</p>")) |
| + } |
| + } |
| + } |
| + |
| + @Test(arguments: ["todo_list=%%%", "todo_list=0123456789abcdef0123456789abcdef", "todo_list="]) |
| + func unknownCookieGetsAFreshList(_ cookie: String) async throws { |
| + try await withApp { client in |
| + let response = try await client.get("/", cookie: cookie) |
| + #expect(response.status == .ok) |
| + #expect(response.listCookie != nil) |
| + #expect(response.listCookie != cookie) |
| + } |
| + } |
| + |
| + @Test func addRedirectsToTheFilterAndTrims() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + for (filter, location) in [("all", "/"), ("active", "/active"), ("completed", "/completed"), ("bogus", "/"), ("", "/")] { |
| + let response = try await client.post("/todos", "title=++x++&filter=\(filter)", cookie: me) |
| + #expect(response.status == .seeOther) |
| + #expect(response.headers[.location] == location) |
| + } |
| + _ = try await client.post("/todos", "title=+++", cookie: me) |
| + let page = try await client.get("/", cookie: me).text |
| + #expect(page.components(separatedBy: "<label>x</label>").count - 1 == 5) |
| + } |
| + } |
| + |
| + @Test func titlesAreEscapedAndKeptExactly() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + let title = "<img src=x onerror=alert(1)> & \"q\" naïve 🚀 日本語" |
| + let encoded = title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! |
| + _ = try await client.post("/todos", "title=\(encoded)", cookie: me) |
| + let response = try await client.get("/", cookie: me) |
| + #expect(response.text.contains("<label><img src=x onerror=alert(1)> & "q" naïve 🚀 日本語</label>")) |
| + #expect(!response.text.contains("<img")) |
| + #expect(response.headers[.contentLength] == String(response.body.readableBytes)) |
| + } |
| + } |
| + |
| + @Test func longTitlesWork() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + let long = String(repeating: "é", count: 10_000) |
| + _ = try await client.post("/todos", "title=\(long.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", cookie: me) |
| + #expect(try await client.get("/", cookie: me).text.contains("<label>\(long)</label>")) |
| + } |
| + } |
| + |
| + @Test func filtersShowTheirItems() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + for t in ["a", "b", "c"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) } |
| + let all = ids(try await client.get("/", cookie: me).text) |
| + _ = try await client.post("/todos/\(all[1])/toggle", "filter=all", cookie: me) |
| + |
| + let active = try await client.get("/active", cookie: me).text |
| + #expect(ids(active) == [all[0], all[2]]) |
| + #expect(active.contains("<a class=\"selected\" href=\"/active\">")) |
| + #expect(active.contains("<strong>2</strong> items left")) |
| + #expect(active.contains("button class=\"clear-completed\"")) |
| + #expect(ids(try await client.get("/completed", cookie: me).text) == [all[1]]) |
| + } |
| + } |
| + |
| + @Test func editViewAndEdits() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) } |
| + let all = ids(try await client.get("/", cookie: me).text) |
| + |
| + let edit = try await client.get("/active?edit=\(all[1])", cookie: me).text |
| + #expect(edit.contains("<li class=\"editing\" data-id=\"\(all[1])\">")) |
| + #expect(edit.contains("<form method=\"post\" action=\"/todos/\(all[1])/edit\"><input type=\"hidden\" name=\"filter\" value=\"active\"><input class=\"edit\" name=\"title\" value=\"b\" autocomplete=\"off\" autofocus>")) |
| + #expect(edit.contains("href=\"/active?edit=\(all[0])\"")) |
| + |
| + let renamed = try await client.post("/todos/\(all[1])/edit", "title=++bee++&filter=active", cookie: me) |
| + #expect(renamed.status == .seeOther) |
| + #expect(renamed.headers[.location] == "/active") |
| + #expect(try await client.get("/", cookie: me).text.contains("<label>bee</label>")) |
| + |
| + _ = try await client.post("/todos/\(all[1])/edit", "title=+&filter=all", cookie: me) |
| + #expect(ids(try await client.get("/", cookie: me).text) == [all[0]]) |
| + } |
| + } |
| + |
| + @Test func toggleAllAndClearCompleted() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + for t in ["a", "b"] { _ = try await client.post("/todos", "title=\(t)", cookie: me) } |
| + _ = try await client.post("/todos/toggle-all", "filter=all", cookie: me) |
| + let page = try await client.get("/", cookie: me).text |
| + #expect(page.contains("class=\"toggle-all\" type=\"checkbox\" checked")) |
| + #expect(page.contains("<strong>0</strong> items left")) |
| + _ = try await client.post("/todos/clear-completed", "filter=all", cookie: me) |
| + #expect(ids(try await client.get("/", cookie: me).text).isEmpty) |
| + } |
| + } |
| + |
| + @Test func anotherListsItemIs404AndUnchanged() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + let them = try await client.newVisitor() |
| + _ = try await client.post("/todos", "title=mine", cookie: me) |
| + let id = ids(try await client.get("/", cookie: me).text)[0] |
| + for action in ["toggle", "edit", "destroy"] { |
| + #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: them).status == .notFound) |
| + #expect(try await client.post("/todos/\(id)/\(action)", "title=x&filter=all", cookie: nil).status == .notFound) |
| + } |
| + #expect(try await client.post("/todos/99999/toggle", "filter=all", cookie: me).status == .notFound) |
| + #expect(try await client.get("/", cookie: me).text.contains("<li data-id=\"\(id)\">")) |
| + #expect(try await client.get("/", cookie: me).text.contains("<label>mine</label>")) |
| + } |
| + } |
| + |
| + @Test func crossOriginPostsAre403() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + for bad in ["https://evil.example", "null", "http://127.0.0.1:8081", "https://127.0.0.1:8080"] { |
| + let response = try await client.post("/todos", "title=evil", cookie: me, headers: [.origin: bad]) |
| + #expect(response.status == .forbidden) |
| + } |
| + #expect(try await client.post("/todos", "title=ok", cookie: me, headers: [.origin: origin]).status == .seeOther) |
| + #expect(try await client.post("/todos", "title=ok2", cookie: me).status == .seeOther) |
| + let page = try await client.get("/", cookie: me).text |
| + #expect(!page.contains("evil")) |
| + #expect(ids(page).count == 2) |
| + } |
| + } |
| + |
| + @Test func partialHeaderAnswersWithTheNewListInstead() async throws { |
| + try await withApp { client in |
| + let me = try await client.newVisitor() |
| + let response = try await client.post("/todos", "title=a&filter=active", cookie: me, headers: [partialHeader: "1"]) |
| + #expect(response.status == .ok) |
| + #expect(response.text.hasPrefix("<section class=\"main\">")) |
| + #expect(response.text.contains("<a class=\"selected\" href=\"/active\">")) |
| + #expect(!response.text.contains("<html")) |
| + } |
| + } |
| + |
| + @Test func staticFilesAreServedVerbatim() async throws { |
| + try await withApp { client in |
| + try await client.execute(uri: "/static/index.css", method: .get) { response in |
| + #expect(response.status == .ok) |
| + #expect(response.headers[.contentType] == "text/css; charset=utf-8") |
| + #expect(response.text == Assets.indexCSS) |
| + } |
| + try await client.execute(uri: "/static/base.css", method: .get) { #expect($0.text == Assets.baseCSS) } |
| + } |
| + } |
| +} |
diff --git a/apps/swift/Tests/TodoAppTests/StoreTests.swift b/apps/swift/Tests/TodoAppTests/StoreTests.swift| new file mode 100644 |
| index 0000000..fb95e56 |
| --- /dev/null |
| +++ b/apps/swift/Tests/TodoAppTests/StoreTests.swift |
| @@ -0,0 +1,120 @@ |
| +import Foundation |
| +import Testing |
| +@testable import TodoApp |
| + |
| +/// A store on a fresh database file, removed afterwards. |
| +func withStore<T>(_ body: (Store, String) async throws -> T) async throws -> T { |
| + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("todo-swift-test-\(UUID().uuidString)") |
| + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| + defer { try? FileManager.default.removeItem(at: dir) } |
| + let path = dir.appendingPathComponent("app.db").path |
| + let store = try await Store.open(path: path) |
| + do { |
| + let result = try await body(store, path) |
| + try await store.close() |
| + return result |
| + } catch { |
| + try? await store.close() |
| + throw error |
| + } |
| +} |
| + |
| +@Suite struct StoreTests { |
| + @Test func usesWALAndRecordsTheSchemaVersion() async throws { |
| + try await withStore { store, path in |
| + #expect(try await store.db.query("PRAGMA journal_mode").first?.column("journal_mode")?.string == "wal") |
| + let version = try await store.db.query("PRAGMA user_version").first?.column("user_version")?.integer |
| + #expect(version == Store.migrations.count) |
| + // Opening again runs nothing twice and keeps the data. |
| + let list = try await store.createList(token: Visitors.newToken()) |
| + try await store.add(list: list, title: "kept") |
| + let again = try await Store.open(path: path) |
| + #expect(try await again.todos(list: list).map(\.title) == ["kept"]) |
| + try await again.close() |
| + } |
| + } |
| + |
| + @Test func toggleAllCompletesEverythingThenReopensEverything() async throws { |
| + try await withStore { store, _ in |
| + let list = try await store.createList(token: Visitors.newToken()) |
| + for title in ["a", "b", "c"] { try await store.add(list: list, title: title) } |
| + let first = try await store.todos(list: list)[0].id |
| + #expect(try await store.toggle(list: list, id: first)) |
| + |
| + try await store.toggleAll(list: list) |
| + #expect(try await store.todos(list: list).allSatisfy(\.completed)) |
| + try await store.toggleAll(list: list) |
| + #expect(try await store.todos(list: list).allSatisfy { !$0.completed }) |
| + } |
| + } |
| + |
| + @Test func listsAreSeparate() async throws { |
| + try await withStore { store, _ in |
| + let mine = try await store.createList(token: Visitors.newToken()) |
| + let theirs = try await store.createList(token: Visitors.newToken()) |
| + try await store.add(list: mine, title: "mine") |
| + let id = try await store.todos(list: mine)[0].id |
| + |
| + #expect(try await store.toggle(list: theirs, id: id) == false) |
| + #expect(try await store.rename(list: theirs, id: id, title: "x") == false) |
| + #expect(try await store.destroy(list: theirs, id: id) == false) |
| + try await store.toggleAll(list: theirs) |
| + try await store.clearCompleted(list: theirs) |
| + #expect(try await store.todos(list: mine) == [Todo(id: id, title: "mine", completed: false)]) |
| + #expect(try await store.todos(list: theirs).isEmpty) |
| + } |
| + } |
| + |
| + @Test func clearCompletedKeepsActiveItems() async throws { |
| + try await withStore { store, _ in |
| + let list = try await store.createList(token: Visitors.newToken()) |
| + for title in ["a", "b", "c"] { try await store.add(list: list, title: title) } |
| + let b = try await store.todos(list: list)[1].id |
| + _ = try await store.toggle(list: list, id: b) |
| + try await store.clearCompleted(list: list) |
| + #expect(try await store.todos(list: list).map(\.title) == ["a", "c"]) |
| + } |
| + } |
| +} |
| + |
| +@Suite struct OptionsTests { |
| + @Test func socketMode() throws { |
| + let o = try Options(arguments: ["--socket", "/run/a.sock", "--db", "/d/app.db"], environment: ["CARLOS_HOST": "todo-swift.bfj.oncarlos.com"]) |
| + #expect(o.address == .socket("/run/a.sock")) |
| + #expect(o.origin == "https://todo-swift.bfj.oncarlos.com") |
| + #expect(o.servedOverHTTPS) |
| + } |
| + |
| + @Test func listenMode() throws { |
| + let o = try Options(arguments: ["--listen", "127.0.0.1:8080", "--db", "x.db"], environment: [:]) |
| + #expect(o.address == .listen(host: "127.0.0.1", port: 8080)) |
| + #expect(o.origin == "http://127.0.0.1:8080") |
| + #expect(!o.servedOverHTTPS) |
| + } |
| + |
| + @Test(arguments: [ |
| + ["--listen", "0.0.0.0:8080", "--db", "x.db"], |
| + ["--listen", ":8080", "--db", "x.db"], |
| + ["--listen", "127.0.0.1:8080"], |
| + ["--socket", "a.sock", "--listen", "127.0.0.1:1", "--db", "x.db"], |
| + ["--db"], |
| + ]) |
| + func refusesBadArguments(_ arguments: [String]) { |
| + #expect(throws: Options.UsageError.self) { try Options(arguments: arguments, environment: [:]) } |
| + } |
| +} |
| + |
| +@Suite struct AssetTests { |
| + static let appDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() |
| + |
| + @Test(arguments: [ |
| + ("../../shared/base.css", Assets.baseCSS), |
| + ("../../shared/index.css", Assets.indexCSS), |
| + ("static/app.css", Assets.appCSS), |
| + ("static/app.js", Assets.appJS), |
| + ]) |
| + func embeddedFilesMatchTheirSources(_ path: String, _ embedded: String) throws { |
| + let source = try Data(contentsOf: Self.appDir.appendingPathComponent(path)) |
| + #expect(Data(embedded.utf8) == source, "run tools/embed.sh") |
| + } |
| +} |
diff --git a/apps/swift/static/app.css b/apps/swift/static/app.css| new file mode 100644 |
| index 0000000..7c772a5 |
| --- /dev/null |
| +++ b/apps/swift/static/app.css |
| @@ -0,0 +1,61 @@ |
| +/* The controls a page without JavaScript uses. app.js's inline starter puts |
| + the class "js" on <html>, and then they're hidden: the checkboxes, |
| + double-click and blur take over, exactly as in TodoMVC. */ |
| +.js .nojs { |
| + display: none !important; |
| +} |
| + |
| +/* Forms that only wrap a control take no part in layout. */ |
| +form.inline { |
| + display: contents; |
| +} |
| + |
| +/* Without JavaScript, a submit button lies exactly over each checkbox, so |
| + the page looks the same either way. */ |
| +.todo-list li .view > button[data-action="toggle"] { |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 40px; |
| + height: 40px; |
| + margin: auto 0; |
| + font-size: 0; |
| + cursor: pointer; |
| + z-index: 1; |
| +} |
| + |
| +.main button[data-action="toggle-all"] { |
| + position: absolute; |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| + font-size: 0; |
| + cursor: pointer; |
| + z-index: 1; |
| +} |
| + |
| +/* Without JavaScript there's no double-click, so each item has an edit link |
| + beside the delete button. */ |
| +.todo-list li .view > a[data-action="edit"] { |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + right: 55px; |
| + height: 40px; |
| + margin: auto 0; |
| + font-size: 20px; |
| + line-height: 40px; |
| + color: #949494; |
| + text-decoration: none; |
| +} |
| + |
| +.todo-list li .view > a[data-action="edit"]:hover, |
| +.todo-list li .view > a[data-action="edit"]:focus { |
| + color: #484848; |
| +} |
| + |
| +html:not(.js) .todo-list li label { |
| + padding-right: 90px; |
| +} |
diff --git a/apps/swift/static/app.js b/apps/swift/static/app.js| new file mode 100644 |
| index 0000000..4a00697 |
| --- /dev/null |
| +++ b/apps/swift/static/app.js |
| @@ -0,0 +1,77 @@ |
| +// TodoMVC behaviour on top of the server-rendered page. Every change is |
| +// still the page's own form post; this sends it with fetch and swaps in |
| +// the new list instead of loading the page again. |
| + |
| +const app = document.querySelector('.todoapp'); |
| + |
| +// Sends a form and replaces section.main and footer.footer with what the |
| +// server answers. Anything unexpected falls back to a full page load. |
| +async function send(form) { |
| + if (form.dataset.sending) return; |
| + form.dataset.sending = '1'; |
| + try { |
| + const res = await fetch(form.action, { |
| + method: 'POST', |
| + headers: { 'X-Todo-Partial': '1' }, |
| + body: new URLSearchParams(new FormData(form)), |
| + }); |
| + if (!res.ok) throw new Error(`${res.status}`); |
| + swap(await res.text()); |
| + if (form.closest('.header')) form.reset(); |
| + } catch { |
| + location.reload(); |
| + } finally { |
| + delete form.dataset.sending; |
| + } |
| +} |
| + |
| +function swap(html) { |
| + const tpl = document.createElement('template'); |
| + tpl.innerHTML = html; |
| + app.querySelectorAll(':scope > .main, :scope > .footer').forEach((el) => el.remove()); |
| + app.append(tpl.content); |
| +} |
| + |
| +app.addEventListener('submit', (e) => { |
| + e.preventDefault(); |
| + send(e.target); |
| +}); |
| + |
| +app.addEventListener('change', (e) => { |
| + const li = e.target.closest('li[data-id]'); |
| + if (e.target.matches('.toggle') && li) send(document.getElementById(`toggle-${li.dataset.id}`)); |
| + if (e.target.matches('.toggle-all')) send(app.querySelector('[data-action="toggle-all"]').form); |
| +}); |
| + |
| +// Editing: double-click opens the editor, Enter or blur saves, Escape |
| +// cancels. An empty title deletes the item (the server decides that). |
| +function startEditing(li) { |
| + li.classList.add('editing'); |
| + const input = li.querySelector('.edit'); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +} |
| + |
| +app.addEventListener('dblclick', (e) => { |
| + const li = e.target.closest('.todo-list li'); |
| + if (li && e.target.matches('.view label')) startEditing(li); |
| +}); |
| + |
| +app.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || !e.target.matches('.edit')) return; |
| + const input = e.target; |
| + input.value = input.defaultValue; |
| + input.closest('li').classList.remove('editing'); |
| + input.blur(); |
| +}); |
| + |
| +app.addEventListener('focusout', (e) => { |
| + if (!e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + if (li.classList.contains('editing')) send(e.target.form); |
| +}); |
| + |
| +// The server's edit view (?edit=<id>) opens with that item's editor; put |
| +// the cursor at the end, as double-click does. |
| +const editing = app.querySelector('.todo-list li.editing'); |
| +if (editing) startEditing(editing); |
diff --git a/apps/swift/tools/embed.sh b/apps/swift/tools/embed.sh| new file mode 100755 |
| index 0000000..9609bc2 |
| --- /dev/null |
| +++ b/apps/swift/tools/embed.sh |
| @@ -0,0 +1,28 @@ |
| +#!/usr/bin/env bash |
| +# Writes Sources/TodoApp/Assets.swift: the shared TodoMVC CSS and this app's |
| +# static files, embedded byte for byte so the binary is the whole app. |
| +# tools/embed.sh <shared-dir> <static-dir> <out.swift> |
| +set -euo pipefail |
| +shared=$1 static=$2 out=$3 |
| +tmp=$(mktemp "$out.XXXXXX") |
| +trap 'rm -f "$tmp"' EXIT |
| +literal() { |
| + if grep -q '"""#' "$1"; then echo "embed.sh: $1 contains \"\"\"#" >&2; exit 1; fi |
| + printf ' static let %s = #"""\n' "$2" |
| + cat "$1" |
| + # A raw multi-line literal drops the newline before its closing |
| + # delimiter, so add one to keep the file's own final newline. |
| + printf '\n"""#\n\n' |
| +} |
| +{ |
| + printf '// GENERATED by tools/embed.sh from shared/ and static/. Do not edit.\n\n' |
| + printf 'enum Assets {\n' |
| + literal "$shared/base.css" baseCSS |
| + literal "$shared/index.css" indexCSS |
| + literal "$static/app.css" appCSS |
| + literal "$static/app.js" appJS |
| + printf '}\n' |
| +} > "$tmp" |
| +if cmp -s "$tmp" "$out"; then exit 0; fi |
| +mv "$tmp" "$out" |
| +trap - EXIT |