diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d191841
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,103 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ linux:
+ # Builds the app, then runs it against a MySQL 8.4 service and drives the whole API
+ # (Scripts/smoke-test.sh).
+ name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }})
+ runs-on: ubuntu-24.04
+ container: swift:${{ matrix.swift }}-noble
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest supported Swift (swift-tools-version 6.2) and the newest.
+ swift: ["6.2", "6.4"]
+ configuration: [debug, release]
+ services:
+ mysql:
+ image: mysql:8.4
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: perfect_template
+ MYSQL_USER: perfect
+ MYSQL_PASSWORD: perfect
+ options: >-
+ --health-cmd "mysqladmin ping -h 127.0.0.1"
+ --health-interval 5s --health-timeout 5s --health-retries 30
+ env:
+ # The job runs in a container, so the service is reached by name on its own port.
+ DB_HOST: mysql
+ DB_PORT: "3306"
+ # Dates must round-trip whatever the process's time zone is.
+ TZ: America/Denver
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install client library
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends pkg-config libmysqlclient-dev curl
+
+ - name: Toolchain
+ run: swift --version
+
+ - name: Build (warnings are errors)
+ run: swift build -c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors
+
+ - name: Smoke test
+ run: |
+ bin="$(swift build -c ${{ matrix.configuration }} --show-bin-path)/PerfectTemplate"
+ "$bin" > server.log 2>&1 &
+ server=$!
+ Scripts/smoke-test.sh || { cat server.log; exit 1; }
+ kill "$server"
+
+ macos:
+ # GitHub's macOS runners have no MySQL server, so the smoke test checks that the app
+ # starts without one and answers 503 for database routes.
+ name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }})
+ runs-on: macos-26
+ timeout-minutes: 30
+ env:
+ TZ: America/Denver
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest Xcode with Swift 6.2, and the runner's default.
+ xcode: ["26.0.1", "26.6"]
+ configuration: [debug, release]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Select Xcode
+ run: |
+ sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ swift --version
+
+ - name: Install client library
+ run: |
+ brew install mysql-client pkgconf
+ # mysql-client is keg-only, so pkg-config can't find mysqlclient.pc without this.
+ echo "PKG_CONFIG_PATH=$(brew --prefix mysql-client)/lib/pkgconfig" >> "$GITHUB_ENV"
+
+ # Homebrew's libmysqlclient is built for the runner's macOS, so ld warns that it is
+ # newer than the package's macOS 12 deployment target. That is a linker warning;
+ # -warnings-as-errors only applies to the Swift compiler, so it doesn't fail the build.
+ - name: Build (warnings are errors)
+ run: swift build -c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors
+
+ - name: Smoke test (no database)
+ run: |
+ bin="$(swift build -c ${{ matrix.configuration }} --show-bin-path)/PerfectTemplate"
+ "$bin" > server.log 2>&1 &
+ server=$!
+ Scripts/smoke-test.sh --no-db || { cat server.log; exit 1; }
+ kill "$server"
diff --git a/.gitignore b/.gitignore
index 9dbe239..b562f8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,71 +1,6 @@
-# Xcode
-#
-# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
-
-## Build generated
-build/
-DerivedData
-
-## Various settings
-*.pbxuser
-!default.pbxuser
-*.mode1v3
-!default.mode1v3
-*.mode2v3
-!default.mode2v3
-*.perspectivev3
-!default.perspectivev3
-xcuserdata
-
-## Other
-*.xccheckout
-*.moved-aside
-*.xcuserstate
-*.xcscmblueprint
-
-## Obj-C/Swift specific
-*.hmap
-*.ipa
-
-## Playgrounds
-timeline.xctimeline
-playground.xcworkspace
-
-# Swift Package Manager
-#
-# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
-# Packages/
.build/
-
-# CocoaPods
-#
-# We recommend against adding the Pods directory to your .gitignore. However
-# you should judge for yourself, the pros and cons are mentioned at:
-# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
-#
-# Pods/
-
-# Carthage
-#
-# Add this line if you want to avoid checking in source code from Carthage dependencies.
-# Carthage/Checkouts
-
-Carthage/Build
-
-# fastlane
-#
-# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
-# screenshots whenever they are needed.
-# For more information about the recommended setup visit:
-# https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md
-
-fastlane/report.xml
-fastlane/screenshots
-
-Packages/
-PerfectTemplate.xcodeproj
-Tests/
-webroot/
-Package.pins
-Package.resolved
-.DS_Store
+.swiftpm/
+*.o
+*.d
+*.DS_Store
+*.log
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..779531d
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,195 @@
+{
+ "originHash" : "991df5574d88eb99488c807f539e88e2ca0383c6a0c37155d94143aa6253c7e9",
+ "pins" : [
+ {
+ "identity" : "perfect-crud",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/PerfectlySoft/Perfect-CRUD.git",
+ "state" : {
+ "revision" : "1f6e0c7515cdf318707de6827ecf559e148f882b",
+ "version" : "3.1.0"
+ }
+ },
+ {
+ "identity" : "perfect-logger",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/PerfectlySoft/Perfect-Logger.git",
+ "state" : {
+ "revision" : "733b69f55fb8e30256ff85f2ffd39af60a41e141",
+ "version" : "4.0.0"
+ }
+ },
+ {
+ "identity" : "perfect-mysql",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/PerfectlySoft/Perfect-MySQL.git",
+ "state" : {
+ "revision" : "58f1ceb5b33e158fa6ead1d7781525ac51f640a3",
+ "version" : "4.0.0"
+ }
+ },
+ {
+ "identity" : "perfect-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/PerfectlySoft/Perfect-NIO.git",
+ "state" : {
+ "revision" : "a1c2595834622335f3e2c8802a037c5ca602c9ac",
+ "version" : "0.1.0"
+ }
+ },
+ {
+ "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" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c",
+ "version" : "1.7.3"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f",
+ "version" : "4.5.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" : "4038b6a4f74a1426f47eb1dd5b4bb5628a15d597",
+ "version" : "1.16.1"
+ }
+ },
+ {
+ "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-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.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/Package.swift b/Package.swift
index a90a1b2..d292bc6 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,16 +1,31 @@
-// swift-tools-version:4.2
-
+// swift-tools-version: 6.2
import PackageDescription
let package = Package(
- name: "PerfectTemplate",
- products: [
- .executable(name: "PerfectTemplate", targets: ["PerfectTemplate"])
- ],
- dependencies: [
- .package(url: "https://github.com/PerfectlySoft/Perfect-HTTPServer.git", from: "3.0.0"),
- ],
- targets: [
- .target(name: "PerfectTemplate", dependencies: ["PerfectHTTPServer"])
- ]
+ name: "PerfectTemplate",
+ platforms: [
+ .macOS(.v12),
+ ],
+ products: [
+ .executable(name: "PerfectTemplate", targets: ["App"])
+ ],
+ dependencies: [
+ .package(url: "https://github.com/PerfectlySoft/Perfect-NIO.git", from: "0.1.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.1.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", from: "4.0.0"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-Logger.git", from: "4.0.0"),
+ .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
+ ],
+ targets: [
+ .executableTarget(
+ name: "App",
+ dependencies: [
+ .product(name: "PerfectNIO", package: "Perfect-NIO"),
+ .product(name: "PerfectCRUD", package: "Perfect-CRUD"),
+ .product(name: "PerfectMySQL", package: "Perfect-MySQL"),
+ .product(name: "PerfectLogger", package: "Perfect-Logger"),
+ .product(name: "NIOPosix", package: "swift-nio"),
+ ]
+ )
+ ]
)
diff --git a/README.md b/README.md
index 3bfed6f..940255e 100644
--- a/README.md
+++ b/README.md
@@ -1,104 +1,166 @@
-# PerfectTemplate [简体中文](README.zh_CN.md)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Perfect Empty Starter Project
-
-This repository holds a blank Perfect project which can be cloned to serve as a starter for new work. It builds with Swift Package Manager and produces a stand-alone HTTP executable.
-
-## Compatibility with Swift
-
-The master branch of this project currently compiles with **Xcode 10** and the **Swift 4.1** or higher toolchain on Ubuntu.
-
-## Building & Running
-
-The following will clone and build an empty starter project and launch the server on port 8181.
-
+# PerfectTemplate
+
+A reference application for the Perfect Swift 6 libraries. It's a small blog-style "Posts"
+CRUD web app that wires together **Perfect-NIO**, **Perfect-CRUD**, **Perfect-MySQL**, and
+**Perfect-Logger**: a JSON API, MySQL persistence, structured logging, and a static browser UI.
+Copy it as the starting point for a new app built on them.
+
+The pre-Swift-6 version of this package is preserved on the [`legacy`](https://github.com/PerfectlySoft/PerfectTemplate/tree/legacy) branch.
+
+## Requirements
+
+- Swift 6.2 or later (swift-tools-version 6.2)
+- macOS 12 or later, or Linux (CI builds on Ubuntu 24.04)
+- The MySQL client library and `pkg-config`:
+ - macOS: `brew install mysql-client pkgconf`, then
+ `export PKG_CONFIG_PATH="$(brew --prefix mysql-client)/lib/pkgconfig"` (the formula is keg-only)
+ - Debian/Ubuntu: `apt-get install libmysqlclient-dev pkg-config`
+- A MySQL 5.7.8+ or MariaDB 10.1+ server (tested with MySQL 8.4 and MariaDB 11.8). The quick
+ start runs MySQL in a container; `mariadb:11.8` works the same way with `MARIADB_*`
+ variables in place of `MYSQL_*`.
+
+On macOS the linker may warn that Homebrew's `libmysqlclient` was built for a newer macOS than
+the package's macOS 12 deployment target. The app still builds and runs on the machine it was
+built on.
+
+## Dependencies
+
+| Package | Version | Products used |
+|---|---|---|
+| [Perfect-NIO](https://github.com/PerfectlySoft/Perfect-NIO) | 0.1.0 | `PerfectNIO` |
+| [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) | 3.1.0 | `PerfectCRUD` |
+| [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | 4.0.0 | `PerfectMySQL` |
+| [Perfect-Logger](https://github.com/PerfectlySoft/Perfect-Logger) | 4.0.0 | `PerfectLogger` |
+| [swift-nio](https://github.com/apple/swift-nio) | 2.65.0 | `NIOPosix` (already used by Perfect-NIO) |
+
+Versions are minimums (`from:`), so `swift package update` picks up compatible releases.
+
+## Quick start
+
+Start MySQL in a container. It is published on host port **13306**, not 3306, so it can't
+collide with a MySQL server you already run; the app's defaults point at it.
+
+```sh
+docker run -d --name perfect-template-mysql -p 13306:3306 \
+ -e MYSQL_ROOT_PASSWORD=change-me \
+ -e MYSQL_DATABASE=perfect_template \
+ -e MYSQL_USER=perfect -e MYSQL_PASSWORD=perfect \
+ mysql:8.4
```
-git clone https://github.com/PerfectlySoft/PerfectTemplate.git
-cd PerfectTemplate
+
+(With Apple's `container` tool, the same command works with `container run`.) Give MySQL a few
+seconds to initialise, then build and run the app from the package root:
+
+```sh
swift run
```
-You should see the following output:
+Open for the overview page and for the
+browser UI, or use the API:
+```sh
+curl http://localhost:9191/api/health
+curl -X POST http://localhost:9191/api/posts \
+ -H "Content-Type: application/json" \
+ -d '{"title":"Hello","body":"World"}'
+curl http://localhost:9191/api/posts
```
-[INFO] Starting HTTP server localhost on 0.0.0.0:8181
+
+The `Post` table is created on the first successful connection. If MySQL
+isn't reachable the server starts anyway: database routes answer 503 until it is. The app
+remembers that the table exists, so if you drop it while the app runs, restart the app.
+
+## Configuration
+
+Everything comes from environment variables (see `Sources/App/Config.swift`). The database
+defaults match the quick start container above.
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `PORT` | `9191` | HTTP port, 1–65535 (the server listens on all interfaces) |
+| `DB_HOST` | `127.0.0.1` | MySQL host |
+| `DB_PORT` | `13306` | MySQL port, 1–65535. Set `3306` for a server on the standard port |
+| `DB_NAME` | `perfect_template` | MySQL database; it must exist |
+| `DB_USER` | `perfect` | MySQL user |
+| `DB_PASS` | `perfect` | MySQL password |
+| `WEBROOT` | `webroot` | Directory with the HTML pages and `static/`, relative to the working directory |
+| `LOG_FILE` | `./perfect-template.log` | File the log is appended to |
+| `LOG_LEVEL` | `info` | `debug`, `info`, `warning`, `error` or `critical`. `debug` also logs the SQL PerfectCRUD runs |
+
+A `PORT` or `DB_PORT` that isn't a whole number from 1 to 65535 stops the app at launch with a
+message saying so.
+
+For example, against an existing server:
+
+```sh
+DB_HOST=db.internal DB_PORT=3306 DB_USER=app DB_PASS=secret swift run
```
-This means the server is running and waiting for connections. Access [http://localhost:8181/](http://127.0.0.1:8181/) to see the greeting. Hit control-c to terminate the server.
-
-## Starter Content
-
-The template file contains a simple "hello, world!" request handler and shows how to serve static files, and compress outgoing content.
-
-```swift
-import PerfectHTTP
-import PerfectHTTPServer
-
-// An example request handler.
-// This 'handler' function can be referenced directly in the configuration below.
-func handler(request: HTTPRequest, response: HTTPResponse) {
- // Respond with a simple message.
- response.setHeader(.contentType, value: "text/html")
- response.appendBody(string: "Hello, world! Hello, world!")
- // Ensure that response.completed() is called when your processing is done.
- response.completed()
-}
-
-// Configure one server which:
-// * Serves the hello world message at :/
-// * Serves static files out of the "./webroot"
-// directory (which must be located in the current working directory).
-// * Performs content compression on outgoing data when appropriate.
-var routes = Routes()
-routes.add(method: .get, uri: "/", handler: handler)
-routes.add(method: .get, uri: "/**",
- handler: StaticFileHandler(documentRoot: "./webroot", allowResponseFilters: true).handleRequest)
-try HTTPServer.launch(name: "localhost",
- port: 8181,
- routes: routes,
- responseFilters: [
- (PerfectHTTPServer.HTTPFilter.contentCompression(data: [:]), HTTPFilterPriority.high)])
+Run from the package root (or set `WEBROOT` to an absolute path), since the pages are read
+from disk at request time. In Xcode, set the scheme's working directory to the package folder.
+
+## Routes
+
+| Method | Path | Description |
+|---|---|---|
+| `GET`, `HEAD` | `/` | Overview page (`webroot/index.html`) |
+| `GET`, `HEAD` | `/posts` | Browser UI for the API (`webroot/posts.html`) |
+| `GET`, `HEAD` | `/static/**` | Files under `webroot/static/` |
+| `GET` | `/api/health` | Health check; doesn't touch the database |
+| `GET` | `/api/posts` | All posts, newest first |
+| `POST` | `/api/posts` | Create a post: `{"title": "…", "body": "…"}` (`body` optional) |
+| `GET` | `/api/posts/:id` | One post |
+| `PUT` | `/api/posts/:id` | Update `title`, `body`, or both |
+| `DELETE` | `/api/posts/:id` | Delete a post |
+
+`POST` and `PUT` take a JSON body sent with `Content-Type: application/json`. A `PUT` writes
+only the fields it sends, so two clients updating different fields don't undo each other.
+
+A post looks like this; `id` is a UUID and `createdAt` an ISO 8601 time in UTC:
+```json
+{"id":"9C3A0D2E-0B7C-4C1E-9E55-2F1F7A0B6E1D","title":"Hello","body":"World","createdAt":"2026-10-15T16:10:51Z"}
```
-## Further Information
-For more information on the Perfect project, please visit [perfect.org](http://perfect.org).
+Errors are plain text with the status code saying what went wrong:
+
+| Status | When |
+|---|---|
+| 400 | The JSON doesn't fit the route (for example, no `title` on create); the title is empty or only invisible characters, over 200 characters or 2,000 Unicode code points, or contains a control character, line or paragraph separator, or bidirectional control; or the body is over 20,000 characters or 100,000 code points. Also any request path with a `..` segment |
+| 404 | No post with that id (or the id isn't a UUID), or no such page or file |
+| 413 | The request body is over 256 KiB |
+| 415 | `POST` or `PUT` without `Content-Type: application/json` |
+| 503 | "Database unavailable.": no connection to MySQL could be opened within 5 seconds, or one failed in the last 2 seconds. "Database busy.": the server gave up on the statement after waiting 20 seconds for a lock, or after it ran 20 seconds (on MySQL only a SELECT is limited; on MariaDB every statement is). Nothing was written, since each request's statements autocommit; code that adds explicit transactions has to roll back itself |
+| 500 | Anything else, such as a failed query or no answer from MySQL within 30 seconds; the details go to the log, not the client |
+
+## How it's put together
+
+- `main.swift` checks the configuration, bootstraps PerfectLogger (console and file), sends
+ PerfectCRUD's own logging to it at debug level, connects to MySQL in the background so the
+ schema exists, and starts the Perfect-NIO `Server` with a 256 KiB request body limit.
+- `DB.swift` has `withDB {}`, which opens a MySQL connection, makes sure the table exists, and
+ runs the work on a pool of 16 threads used only for the database (a swift-nio
+ `NIOThreadPool`). Blocking libmysqlclient calls stay off Swift's cooperative thread pool, and
+ when MySQL is slow or a table is locked, at most 16 requests wait on it while the health
+ check, pages and other requests keep answering. Connecting times out after 5 seconds, and
+ after a failed attempt requests answer 503 at once for 2 seconds rather than each waiting
+ again. Each connection tells the server to give up on a statement that waits over 20 seconds
+ for a lock, or runs over 20 seconds (MySQL's `max_execution_time` limits only SELECTs;
+ MariaDB's `max_statement_time` limits every statement), before the client's own 30-second
+ read timeout: a request that fails that way has not written anything, and the server isn't
+ left working on a session nobody is waiting for. It opens a connection per request; for a busy
+ service, look at PerfectCRUD's `DatabaseConnectionPool`.
+- `Routes/APIRoutes.swift` is the JSON API, with input validation and error mapping.
+- `Routes/WebRoutes.swift` serves the pages and static files. Perfect-NIO refuses any request
+ path with a `..` segment before routing, so static paths can't leave `webroot/static`.
+- `Models/Post.swift` is the PerfectCRUD model and request types.
+
+## Checking a running server
+
+`Scripts/smoke-test.sh` drives every API route against a running server (create, list, get,
+update, delete, and the error cases). `Scripts/smoke-test.sh --no-db` checks a server that has no
+database: health and pages answer 200 and `/api/posts` answers 503. CI runs both.
+
+## License
+
+Apache 2.0; see [LICENSE](LICENSE) ([中文](LICENSE.zh_CN)).
diff --git a/README.zh_CN.md b/README.zh_CN.md
deleted file mode 100644
index a87a930..0000000
--- a/README.zh_CN.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# PerfectTemplate [English](https://github.com/PerfectlySoft/PerfectTemplate)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Perfect Web服务器项目模板
-
-本代码用于软件工程师在此基础之上开发Web服务器及其应用。您可以直接克隆本项目进行后续开发。该项目通过SPM软件包管理器编译,并能够生成一个可以独立运行的HTTP服务器。
-
-###Swift兼容性
-
-本项目目前使用Swift 4.0工具链(Ubuntu)或Xcode 9编译。
-
-## 编译运行
-
-下列命令行可以克隆并在8080和8181端口编译并启动 HTTP 服务器:
-
-```
-git clone https://github.com/PerfectlySoft/PerfectTemplate.git
-cd PerfectTemplate
-swift build
-.build/debug/PerfectTemplate
-```
-
-如果没有问题,输出应该看起来像是这样:
-
-```
-[INFO] Starting HTTP server localhost on 0.0.0.0:8181
-[INFO] Starting HTTP server localhost on 0.0.0.0:8080
-```
-
-这表明服务器已经准备好并且等待连接了。请访问[http://localhost:8181/](http://127.0.0.1:8181/) 来查看欢迎信息。在终端命令行上输入control-c组合键即可停止Web服务。
-
-## 快速上手
-
-模板项目包含了一个简单的“你好,世界!”页面,能够压缩传输内容并同时启动多个服务器。
-
-``` swift
-import PerfectLib
-import PerfectHTTP
-import PerfectHTTPServer
-
-// 页面控制器
-// 以下“页面句柄”可以直接引用和配置
-func handler(data: [String:Any]) throws -> RequestHandler {
- return {
- request, response in
- // 响应一个简单的页面
- response.setHeader(.contentType, value: "text/html")
- response.appendBody(string: "你好,世界! 你好,世界!")
- // 在页面内容完成后必须主动调用 response.completed() 完成响应
- response.completed()
- }
-}
-
-// 同时配置启动两个服务器
-// 以下配置例子显示了如何同时启动一个以上的服务器
-// 使用一个字典数据作为配置文件
-
-let port1 = 8080, port2 = 8181
-
-let confData = [
- "servers": [
- // 1号服务器配置
- // * :/ 服务器下显示“你好,世界!”
- // * 提供 "./webroot" 下的文件访问,该文件夹必须设置在当前工作目录下
- // * 执行页面和传输压缩
- [
- "name":"localhost",
- "port":port1,
- "routes":[
- ["method":"get", "uri":"/", "handler":handler],
- ["method":"get", "uri":"/**", "handler":PerfectHTTPServer.HTTPHandler.staticFiles,
- "documentRoot":"./webroot",
- "allowResponseFilters":true]
- ],
- "filters":[
- [
- "type":"response",
- "priority":"high",
- "name":PerfectHTTPServer.HTTPFilter.contentCompression,
- ]
- ]
- ],
- // 2号服务器配置数据
- // * 将数据重定向返回给1号服务器
- [
- "name":"localhost",
- "port":port2,
- "routes":[
- ["method":"get", "uri":"/**", "handler":PerfectHTTPServer.HTTPHandler.redirect,
- "base":"http://localhost:\(port1)"]
- ]
- ]
- ]
-]
-
-do {
- // 使用配置信息启动服务器
- try HTTPServer.launch(configurationData: confData)
-} catch {
- fatalError("\(error)") // 启动异常
-}
-```
-
-
-
-## 问题报告
-
-目前我们已经把所有错误报告合并转移到了JIRA上,因此github原有的错误汇报功能不能用于本项目。
-
-您的任何宝贵建意见或建议,或者发现我们的程序有问题,欢迎您在这里告诉我们。[http://jira.perfect.org:8080/servicedesk/customer/portal/1](http://jira.perfect.org:8080/servicedesk/customer/portal/1)。
-
-目前问题清单请参考以下链接: [http://jira.perfect.org:8080/projects/ISS/issues](http://jira.perfect.org:8080/projects/ISS/issues)
-
-
-
-## 更多内容
-关于Perfect更多内容,请参考[perfect.org](http://perfect.org)官网。
diff --git a/Scripts/smoke-test.sh b/Scripts/smoke-test.sh
new file mode 100755
index 0000000..3dba10d
--- /dev/null
+++ b/Scripts/smoke-test.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Smoke test for a running PerfectTemplate server.
+#
+# Scripts/smoke-test.sh [base-url] full API round trip (needs MySQL)
+# Scripts/smoke-test.sh --no-db [base-url] health, pages, and 503 from /api/posts
+#
+# The base URL defaults to http://127.0.0.1:9191. Waits up to 60 seconds for /api/health.
+set -euo pipefail
+
+no_db=false
+if [ "${1:-}" = "--no-db" ]; then no_db=true; shift; fi
+base="${1:-http://127.0.0.1:9191}"
+
+fail() { echo "FAIL: $*" >&2; exit 1; }
+
+# request METHOD PATH [JSON-BODY] -> sets $status and $body
+request() {
+ local out
+ if [ $# -ge 3 ]; then
+ out=$(curl -sS -X "$1" -H 'Content-Type: application/json' --data "$3" -w '\n%{http_code}' "$base$2")
+ else
+ out=$(curl -sS -X "$1" -w '\n%{http_code}' "$base$2")
+ fi
+ status="${out##*$'\n'}"
+ body="${out%$'\n'*}"
+}
+
+expect() { # expect STATUS [SUBSTRING] DESCRIPTION
+ local want="$1" text="$2" what="$3"
+ [ "$status" = "$want" ] || fail "$what: expected $want, got $status: $body"
+ [ -z "$text" ] || [[ "$body" == *"$text"* ]] || fail "$what: body lacks '$text': $body"
+ echo "ok $what ($status)"
+}
+
+for _ in $(seq 1 60); do
+ curl -sf "$base/api/health" > /dev/null 2>&1 && break
+ sleep 1
+done
+
+request GET /api/health; expect 200 '"status":"ok"' "GET /api/health"
+request GET /; expect 200 'PerfectTemplate' "GET /"
+request GET /posts; expect 200 'Posts' "GET /posts"
+request GET /static/favicon.svg; expect 200 ' String? {
+ guard let value = ProcessInfo.processInfo.environment[key], !value.isEmpty else { return nil }
+ return value
+ }
+
+ private static func portNumber(_ key: String, default defaultValue: Int) -> Int {
+ guard let value = env(key) else { return defaultValue }
+ guard let port = Int(value), (1...65535).contains(port) else {
+ FileHandle.standardError.write(Data("\(key) must be a port number from 1 to 65535, not \(String(reflecting: value)).\n".utf8))
+ exit(1)
+ }
+ return port
+ }
+}
diff --git a/Sources/App/DB.swift b/Sources/App/DB.swift
new file mode 100644
index 0000000..f9299f0
--- /dev/null
+++ b/Sources/App/DB.swift
@@ -0,0 +1,174 @@
+import Foundation
+import NIOPosix
+import PerfectNIO
+import PerfectCRUD
+import PerfectMySQL
+import PerfectLogger
+
+typealias DB = Database
+
+/// Runs `body` with its own MySQL connection and returns its result.
+///
+/// libmysqlclient calls block their thread, and a route handler runs on Swift's cooperative
+/// thread pool, which must never block. So the whole unit of work (connect, make sure the
+/// schema exists, run the queries) runs on `dbThreads`, a fixed pool of threads used only for
+/// the database, while the route awaits it. When MySQL is slow or a table is locked, at most
+/// that many requests wait inside MySQL and the rest queue for a thread; the health check,
+/// the pages and everything else keep answering.
+///
+/// Each call opens a new connection and closes it when `body` returns. That keeps the example
+/// simple and recovers by itself when MySQL restarts. A busy service would reuse connections
+/// with PerfectCRUD's `DatabaseConnectionPool` instead, and then has to deal with pooled
+/// connections that went stale.
+///
+/// The route answers 503 Service Unavailable when no connection can be opened within
+/// `connectTimeout` seconds (or one failed in the last `retryDelay` seconds), and when MySQL
+/// gives up on a statement that waited too long for a lock or ran too long. Any other error
+/// becomes a 500 whose details are logged by Perfect-NIO, not sent to the client.
+func withDB(_ body: @escaping @Sendable (DB) throws -> T) async throws -> T {
+ try await dbThreads.runIfActive {
+ let db = try connect()
+ do {
+ try schema.ensure(db)
+ return try body(db)
+ } catch where isBusyError(error) {
+ LogFile.warning("MySQL gave up on a statement: \(error)")
+ throw ErrorOutput(status: .serviceUnavailable, description: "Database busy.")
+ }
+ }
+}
+
+/// Threads that run database work. Also the most MySQL connections the app opens at once.
+private let dbThreads: NIOThreadPool = {
+ let pool = NIOThreadPool(numberOfThreads: 16)
+ pool.start()
+ return pool
+}()
+
+/// Seconds to wait for MySQL to accept a connection.
+private let connectTimeout = 5
+/// Seconds a single read from or write to MySQL may take before the client gives up.
+private let ioTimeout = 30
+/// Seconds MySQL itself lets a statement wait for a lock, or run (MySQL: SELECTs only;
+/// MariaDB: every statement). Shorter than
+/// `ioTimeout`, so MySQL abandons the statement before the client stops waiting for it: a
+/// request that fails has not written anything, and the server isn't left holding sessions.
+private let statementTimeout = 20
+/// Whether the server abandoned the statement because of the limits above:
+/// ER_LOCK_WAIT_TIMEOUT (1205), MySQL's ER_QUERY_TIMEOUT (3024), or MariaDB's
+/// ER_STATEMENT_TIMEOUT (1969).
+///
+/// Perfect-MySQL 4.0.0's MySQLCRUDError carries only text, and the connection's error code is
+/// already reset when the error reaches us, so this matches the servers' (English) messages.
+/// It only looks at errors Perfect-MySQL raised for a statement the server rejected, whose
+/// descriptions start with one of `serverErrorPrefixes`; anything else (say, a decoding error
+/// that quotes a stored value) is never "busy". A Perfect-MySQL release whose error carries
+/// the errno would let this check for 1205, 3024 and 1969 instead.
+private func isBusyError(_ error: any Error) -> Bool {
+ guard let error = error as? MySQLCRUDError,
+ serverErrorPrefixes.contains(where: error.description.hasPrefix) else {
+ return false
+ }
+ return error.description.contains("Lock wait timeout exceeded") // 1205
+ || error.description.contains("maximum statement execution time exceeded") // 3024
+ || error.description.contains("max_statement_time exceeded") // 1969
+}
+
+/// How Perfect-MySQL 4.0.0 begins the description of an error the server reported while
+/// running a statement, or while sending its rows.
+private let serverErrorPrefixes = ["Error executing statement. ", "Error reading results. "]
+
+private func connect() throws -> DB {
+ try outage.check()
+ let mysql = MySQL()
+ mysql.setOption(.MYSQL_SET_CHARSET_NAME, "utf8mb4")
+ mysql.setOption(.MYSQL_OPT_CONNECT_TIMEOUT, connectTimeout)
+ mysql.setOption(.MYSQL_OPT_READ_TIMEOUT, ioTimeout)
+ mysql.setOption(.MYSQL_OPT_WRITE_TIMEOUT, ioTimeout)
+ // Runs on every new connection. lock_wait_timeout covers table and metadata locks
+ // (LOCK TABLES, DDL) and innodb_lock_wait_timeout row locks, on MySQL and MariaDB alike.
+ mysql.setOption(.MYSQL_INIT_COMMAND, """
+ SET SESSION lock_wait_timeout = \(statementTimeout), \
+ innodb_lock_wait_timeout = \(statementTimeout)
+ """)
+ guard mysql.connect(host: Config.dbHost, user: Config.dbUser, password: Config.dbPass,
+ db: Config.dbName, port: UInt32(Config.dbPort)) else {
+ outage.failed(mysql.errorMessage())
+ throw ErrorOutput(status: .serviceUnavailable, description: "Database unavailable.")
+ }
+ outage.connected()
+ // The running-time limit has a different name on each server: max_execution_time in
+ // milliseconds on MySQL 5.7.8+ (SELECTs only), max_statement_time in seconds on
+ // MariaDB 10.1+ (every statement). Neither knows the other's, so try them in turn.
+ if !mysql.query(statement: "SET SESSION max_execution_time = \(statementTimeout * 1000)"),
+ !mysql.query(statement: "SET SESSION max_statement_time = \(statementTimeout)") {
+ LogFile.warning("could not limit statement time: \(mysql.errorMessage())")
+ }
+ return Database(configuration: MySQLDatabaseConfiguration(connection: mysql))
+}
+
+/// Seconds after a failed connection during which requests get 503 at once, instead of each
+/// waiting up to `connectTimeout` for MySQL again.
+private let retryDelay: TimeInterval = 2
+
+/// Remembers the last failed connection, for the fast 503 above, and logs failures at most
+/// once every 10 seconds so an outage under load doesn't write a line per request.
+private let outage = Outage()
+
+private final class Outage: @unchecked Sendable {
+ private let lock = NSLock()
+ private var lastFailure: Date?
+ private var lastLogged: Date?
+ private var suppressed = 0
+
+ func check() throws {
+ lock.lock()
+ defer { lock.unlock() }
+ if let lastFailure, Date().timeIntervalSince(lastFailure) < retryDelay {
+ suppressed += 1
+ throw ErrorOutput(status: .serviceUnavailable, description: "Database unavailable.")
+ }
+ }
+
+ func connected() {
+ lock.lock()
+ defer { lock.unlock() }
+ lastFailure = nil
+ }
+
+ func failed(_ message: String) {
+ lock.lock()
+ defer { lock.unlock() }
+ let now = Date()
+ lastFailure = now
+ if let lastLogged, now.timeIntervalSince(lastLogged) < 10 {
+ suppressed += 1
+ return
+ }
+ let more = suppressed > 0 ? " (\(suppressed) more failed requests since the last report)" : ""
+ LogFile.error("cannot connect to MySQL at \(Config.dbHost):\(Config.dbPort): \(message)\(more)")
+ lastLogged = now
+ suppressed = 0
+ }
+}
+
+/// Creates the posts table the first time a connection succeeds. That is normally at startup,
+/// but if MySQL was down then, the first request after it comes back does it instead.
+///
+/// `ready` is remembered for the life of the process, so if the table is dropped while the
+/// app is running, restart the app to create it again.
+private let schema = Schema()
+
+private final class Schema: @unchecked Sendable {
+ private let lock = NSLock()
+ private var ready = false
+
+ func ensure(_ db: DB) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !ready else { return }
+ try db.create(Post.self, policy: .shallow)
+ ready = true
+ LogFile.info("database schema ready")
+ }
+}
diff --git a/Sources/App/Models/Post.swift b/Sources/App/Models/Post.swift
new file mode 100644
index 0000000..3e63511
--- /dev/null
+++ b/Sources/App/Models/Post.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+/// A row in the `posts` table. PerfectCRUD maps the properties to columns: `id` is the
+/// primary key (VARCHAR(36) in MySQL) and `createdAt` is a DATETIME, stored to the second.
+struct Post: Codable, Sendable {
+ var id: UUID
+ var title: String
+ var body: String
+ var createdAt: Date
+}
+
+/// Body of `POST /api/posts`.
+struct CreatePostRequest: Codable, Sendable {
+ var title: String
+ var body: String?
+}
+
+/// Body of `PUT /api/posts/:id`. Fields left out keep their current values.
+struct UpdatePostRequest: Codable, Sendable {
+ var title: String?
+ var body: String?
+}
+
+/// Input limits. The server also refuses request bodies over `maxRequestBodySize` (see main.swift).
+enum PostLimits {
+ /// Characters, as a reader counts them.
+ static let titleLength = 200
+ static let bodyLength = 20_000
+ /// Unicode code points. Generous for real text (an emoji family is 7), but bounded.
+ static let titleCodePoints = 2_000
+ static let bodyCodePoints = 100_000
+}
diff --git a/Sources/App/Routes/APIRoutes.swift b/Sources/App/Routes/APIRoutes.swift
new file mode 100644
index 0000000..371819b
--- /dev/null
+++ b/Sources/App/Routes/APIRoutes.swift
@@ -0,0 +1,231 @@
+import Foundation
+import PerfectNIO
+import PerfectCRUD
+import PerfectLogger
+
+/// All routes under /api
+///
+/// GET /api/health — server health check, no database
+/// GET /api/posts — list all posts, newest first
+/// POST /api/posts — create a post (body: {"title":"…","body":"…"})
+/// GET /api/posts/:id — get one post by id
+/// PUT /api/posts/:id — partial update (body: {"title":"…"} or {"body":"…"} or both)
+/// DELETE /api/posts/:id — delete a post
+///
+/// POST and PUT take JSON only (Content-Type: application/json). Successful responses are
+/// JSON. Errors are plain text with the matching status: 400 for invalid input, 404 for an
+/// unknown id, 413 for an oversized body, 415 for a body that isn't JSON, 503 when no MySQL
+/// connection can be opened, and 500 for anything else.
+func apiRoutes() throws -> Routes {
+
+ // ── Health ────────────────────────────────────────────────────────────────
+
+ let health = root().GET.api.health.map { _ -> HTTPOutput in
+ try json(["status": "ok", "time": ISO8601DateFormatter().string(from: Date())])
+ }
+
+ // ── Posts: collection ─────────────────────────────────────────────────────
+
+ let listPosts = root().GET.api.posts.map { _ -> HTTPOutput in
+ let posts = try await withDB { db in
+ // all() throws if the query fails. Iterating select() as a Sequence would
+ // instead log the error and yield no rows, turning a failure into "[]".
+ try db.table(Post.self).order(descending: \Post.createdAt, \Post.id).select().all()
+ }
+ return try json(posts)
+ }
+
+ // decode() reads the request body and passes it alongside the HTTPRequest. A body that
+ // doesn't fit CreatePostRequest is answered 400 by Perfect-NIO.
+ let createPost = root().POST.api.posts.requireJSON().decode(CreatePostRequest.self) { _, input -> HTTPOutput in
+ let post = Post(
+ id: UUID(),
+ title: try validTitle(input.title),
+ body: try validBody(input.body ?? ""),
+ createdAt: Date()
+ )
+ try await withDB { db in _ = try db.table(Post.self).insert(post) }
+ LogFile.info("created post id=\(post.id) title=\(logSafe(post.title))")
+ return try json(post, status: .created)
+ }
+
+ // ── Posts: single resource ────────────────────────────────────────────────
+
+ // wild() captures the next path segment as a String.
+ let getPost = root().GET.api.posts.wild { _, segment -> HTTPOutput in
+ let id = try postID(segment)
+ guard let post = try await withDB({ db in
+ try db.table(Post.self).where(\Post.id == id).first()
+ }) else {
+ throw notFound(id)
+ }
+ return try json(post)
+ }
+
+ // wild(name:) stores the captured segment in req.uriVariables so it's
+ // still accessible after chaining decode().
+ let updatePost = root().PUT.api.posts
+ .wild(name: "id")
+ .requireJSON()
+ .decode(UpdatePostRequest.self) { req, input -> HTTPOutput in
+ let id = try postID(req.uriVariables["id"] ?? "")
+ let title = try input.title.map(validTitle)
+ let body = try input.body.map(validBody)
+ guard let post = try await withDB({ db -> Post? in
+ let match = db.table(Post.self).where(\Post.id == id)
+ guard var post = try match.first() else { return nil }
+ // Write only the fields the request sent. Updating the whole row would put
+ // back the other field as read above, undoing a concurrent update to it.
+ switch (title, body) {
+ case let (title?, body?):
+ post.title = title
+ post.body = body
+ try match.update(post, setKeys: \.title, \.body)
+ case let (title?, nil):
+ post.title = title
+ try match.update(post, setKeys: \.title)
+ case let (nil, body?):
+ post.body = body
+ try match.update(post, setKeys: \.body)
+ case (nil, nil):
+ return post
+ }
+ // Read the row back, so the response shows what is stored now.
+ return try match.first()
+ }) else {
+ throw notFound(id)
+ }
+ LogFile.info("updated post id=\(id)")
+ return try json(post)
+ }
+
+ let deletePost = root().DELETE.api.posts.wild { _, segment -> HTTPOutput in
+ let id = try postID(segment)
+ let deleted = try await withDB { db -> Bool in
+ // PerfectCRUD's typed delete() doesn't report how many rows it removed; the dynamic
+ // API does, so one statement both deletes and tells us whether the post existed.
+ // The table and key names come from the model, as the typed API would use them.
+ let structure = try Post.CRUDTableStructure()
+ let result = try db.mutate(DynamicMutation(
+ action: .delete,
+ table: structure.tableName,
+ predicates: [DynamicPredicate(field: structure.primaryKeyName ?? "id",
+ comparison: .equal, value: .string(id.uuidString))]
+ ))
+ return result.affectedRows > 0
+ }
+ guard deleted else { throw notFound(id) }
+ LogFile.info("deleted post id=\(id)")
+ return try json(["deleted": id])
+ }
+
+ return try root().dir(health, listPosts, createPost, getPost, updatePost, deletePost)
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+extension Routes {
+ /// Answers 415 unless the request says its body is JSON. Besides keeping the API honest,
+ /// this stops another site's page from creating posts with a plain HTML form or a
+ /// text/plain fetch, which browsers send cross-site without asking the server first.
+ func requireJSON() -> Routes {
+ request { output, req in
+ let mediaType = req.contentType?
+ .split(separator: ";").first?
+ .trimmingCharacters(in: .whitespaces)
+ .lowercased()
+ guard mediaType == "application/json" else {
+ throw ErrorOutput(status: .unsupportedMediaType, description: "Send the body as JSON with Content-Type: application/json.")
+ }
+ return output
+ }
+ }
+}
+
+/// JSON response with dates as ISO 8601 strings ("2026-10-15T17:04:05Z").
+/// (Perfect-NIO's JSONOutput uses a default JSONEncoder, which writes dates as numbers.)
+func json(_ value: T, status: HTTPResponseStatus = .ok) throws -> HTTPOutput {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ return BytesOutput(
+ head: HTTPHead(status: status, headers: HTTPHeaders([("Content-Type", "application/json")])),
+ body: Array(try encoder.encode(value))
+ )
+}
+
+/// User text for a log line: quoted, with everything outside printable ASCII escaped, so it
+/// can't break the line or hide behind invisible or reordering characters.
+private func logSafe(_ text: String) -> String {
+ "\"" + text.unicodeScalars.map { $0.escaped(asASCII: true) }.joined() + "\""
+}
+
+/// Ids are UUIDs. Anything else can't name a post, so it is a 404 without a database query.
+private func postID(_ segment: String) throws -> UUID {
+ guard let id = UUID(uuidString: segment) else {
+ throw ErrorOutput(status: .notFound, description: "No such post.")
+ }
+ return id
+}
+
+private func notFound(_ id: UUID) -> ErrorOutput {
+ ErrorOutput(status: .notFound, description: "No post with id \(id).")
+}
+
+/// A title is one line of at most `PostLimits.titleLength` characters with something visible
+/// in it. Control characters, line and paragraph separators, and the bidirectional controls
+/// that can make text display in a different order than it is stored are refused. Other
+/// invisible format characters are allowed (emoji sequences need the zero-width joiner), but
+/// not on their own.
+private func validTitle(_ title: String) throws -> String {
+ let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
+ try checkLength(title, name: "Title", characters: PostLimits.titleLength, codePoints: PostLimits.titleCodePoints)
+ guard !title.unicodeScalars.contains(where: isRefusedInTitle) else {
+ throw ErrorOutput(status: .badRequest, description: "Title must be a single line of plain text.")
+ }
+ guard title.unicodeScalars.contains(where: isVisible) else {
+ throw ErrorOutput(status: .badRequest, description: "Title must not be empty.")
+ }
+ return title
+}
+
+private func validBody(_ body: String) throws -> String {
+ try checkLength(body, name: "Body", characters: PostLimits.bodyLength, codePoints: PostLimits.bodyCodePoints)
+ return body
+}
+
+/// Limits what a reader sees (characters) and, separately, the Unicode code points behind
+/// them, so one character stacked with thousands of combining marks can't slip through.
+private func checkLength(_ text: String, name: String, characters: Int, codePoints: Int) throws {
+ guard text.count <= characters else {
+ throw ErrorOutput(status: .badRequest, description: "\(name) must be at most \(grouped(characters)) characters.")
+ }
+ guard text.unicodeScalars.count <= codePoints else {
+ throw ErrorOutput(status: .badRequest, description: "\(name) is too long (at most \(grouped(codePoints)) Unicode code points).")
+ }
+}
+
+/// 20000 → "20,000", the same in every locale.
+private func grouped(_ n: Int) -> String {
+ var digits = String(n)
+ var i = digits.endIndex
+ while digits.distance(from: digits.startIndex, to: i) > 3 {
+ i = digits.index(i, offsetBy: -3)
+ digits.insert(",", at: i)
+ }
+ return digits
+}
+
+private func isRefusedInTitle(_ scalar: Unicode.Scalar) -> Bool {
+ switch scalar.value {
+ case 0x2028, 0x2029: // line and paragraph separators
+ return true
+ case 0x202A...0x202E, 0x2066...0x2069: // bidirectional embedding, override and isolate controls
+ return true
+ default:
+ return scalar.properties.generalCategory == .control
+ }
+}
+
+private func isVisible(_ scalar: Unicode.Scalar) -> Bool {
+ !scalar.properties.isWhitespace && scalar.properties.generalCategory != .format
+}
diff --git a/Sources/App/Routes/WebRoutes.swift b/Sources/App/Routes/WebRoutes.swift
new file mode 100644
index 0000000..d0b5cf5
--- /dev/null
+++ b/Sources/App/Routes/WebRoutes.swift
@@ -0,0 +1,22 @@
+import PerfectNIO
+
+/// GET / → serves webroot/index.html
+/// GET /posts → serves webroot/posts.html (CRUD UI)
+/// GET /static/** → serves any file under webroot/static/
+///
+/// Each also answers HEAD: Perfect-NIO sends the headers GET would, without the body.
+///
+/// Perfect-NIO answers 400 to any request path with a ".." segment (raw or percent-encoded)
+/// before routing, so the trailing path can't climb out of webroot/static.
+func webRoutes() throws -> Routes {
+ let index = root().method(.GET, .HEAD).map { _ -> HTTPOutput in
+ try FileOutput(localPath: "\(Config.webroot)/index.html")
+ }
+ let postsPage = root().method(.GET, .HEAD).path("posts").map { _ -> HTTPOutput in
+ try FileOutput(localPath: "\(Config.webroot)/posts.html")
+ }
+ let staticFiles = root().method(.GET, .HEAD).path("static").trailing { _, path -> HTTPOutput in
+ try FileOutput(localPath: "\(Config.webroot)/static/\(path)")
+ }
+ return try root().dir(index, postsPage, staticFiles)
+}
diff --git a/Sources/App/main.swift b/Sources/App/main.swift
new file mode 100644
index 0000000..00d7d11
--- /dev/null
+++ b/Sources/App/main.swift
@@ -0,0 +1,69 @@
+import Foundation
+import PerfectNIO
+import PerfectCRUD
+import PerfectLogger
+
+// Stop at once on a malformed PORT or DB_PORT.
+Config.validate()
+
+// Configure logging first: console + a file, at the configured level.
+// Every LogFile.* call (and any swift-log Logger inside the Perfect libraries)
+// flows through these handlers. Bootstrap must run exactly once, before any log.
+PerfectLogger.bootstrap(
+ console: true,
+ file: Config.logFile,
+ level: Config.logLevel.level
+)
+LogFile.info("PerfectTemplate starting (log level \(Config.logLevel), file \(Config.logFile))")
+LogFile.info("database \(Config.dbName) on \(Config.dbHost):\(Config.dbPort) as \(Config.dbUser)")
+
+// PerfectCRUD prints the SQL it runs, and its own errors, to stdout. Send both to the log at
+// debug level instead: the errors that matter reach the log anyway, from the route that hit them.
+// Whitespace (CREATE TABLE spans lines) is collapsed so each event stays on one log line.
+let crudToLog = CRUDLogDestination.custom { event in
+ let message = event.msg.split(whereSeparator: \.isWhitespace).joined(separator: " ")
+ LogFile.debug("PerfectCRUD \(event.type): \(message)")
+}
+CRUDLogging.queryLogDestinations = [crudToLog]
+CRUDLogging.errorLogDestinations = [crudToLog]
+
+if !FileManager.default.fileExists(atPath: "\(Config.webroot)/index.html") {
+ LogFile.warning("\(Config.webroot)/index.html not found; run from the package root or set WEBROOT")
+}
+
+// Connect once in the background so the posts table is created and a bad configuration shows
+// up in the log right away, without delaying startup. If MySQL is unreachable the server still
+// runs: database routes answer 503 until it is, and the first one that connects creates the table.
+Task.detached {
+ do {
+ try await withDB { _ in }
+ } catch {
+ LogFile.warning("MySQL is not reachable yet; database routes will answer 503 until it is")
+ }
+}
+
+// Request bodies are held in memory, so cap them well below Perfect-NIO's 10 MiB default.
+// Larger requests are refused with 413 before their body is read.
+let server = Server(routes: try allRoutes(), port: Config.port, maxRequestBodySize: 256 * 1024)
+var listening = false
+do {
+ try await server.withServer { boundPort in
+ listening = true
+ LogFile.info("serving on http://localhost:\(boundPort)")
+ // Serve until the process is stopped.
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 3600 * 1_000_000_000)
+ }
+ }
+} catch {
+ LogFile.critical(listening ? "server stopped: \(error)" : "could not start: \(error)")
+ exit(1)
+}
+
+// ── Route composition ─────────────────────────────────────────────────────────
+
+func allRoutes() throws -> Routes {
+ let web = try webRoutes()
+ let api = try apiRoutes()
+ return try root().dir(web, api)
+}
diff --git a/Sources/PerfectTemplate/main.swift b/Sources/PerfectTemplate/main.swift
deleted file mode 100644
index b950ae3..0000000
--- a/Sources/PerfectTemplate/main.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-//
-// main.swift
-// PerfectTemplate
-//
-// Created by Kyle Jessup on 2015-11-05.
-// Copyright (C) 2015 PerfectlySoft, Inc.
-//
-//===----------------------------------------------------------------------===//
-//
-// This source file is part of the Perfect.org open source project
-//
-// Copyright (c) 2015 - 2016 PerfectlySoft Inc. and the Perfect project authors
-// Licensed under Apache License v2.0
-//
-// See http://perfect.org/licensing.html for license information
-//
-//===----------------------------------------------------------------------===//
-//
-
-import PerfectHTTP
-import PerfectHTTPServer
-
-// An example request handler.
-// This 'handler' function can be referenced directly in the configuration below.
-func handler(request: HTTPRequest, response: HTTPResponse) {
- // Respond with a simple message.
- response.setHeader(.contentType, value: "text/html")
- response.appendBody(string: "Hello, world! Hello, world!")
- // Ensure that response.completed() is called when your processing is done.
- response.completed()
-}
-
-// Configure one server which:
-// * Serves the hello world message at :/
-// * Serves static files out of the "./webroot"
-// directory (which must be located in the current working directory).
-// * Performs content compression on outgoing data when appropriate.
-var routes = Routes()
-routes.add(method: .get, uri: "/", handler: handler)
-routes.add(method: .get, uri: "/**",
- handler: StaticFileHandler(documentRoot: "./webroot", allowResponseFilters: true).handleRequest)
-try HTTPServer.launch(name: "localhost",
- port: 8181,
- routes: routes,
- responseFilters: [
- (PerfectHTTPServer.HTTPFilter.contentCompression(data: [:]), HTTPFilterPriority.high)])
-
diff --git a/webroot/index.html b/webroot/index.html
new file mode 100644
index 0000000..15fb651
--- /dev/null
+++ b/webroot/index.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+ PerfectTemplate
+
+
+
+
+
+PerfectTemplate
+A Swift 6 starter built on PerfectNIO + PerfectCRUD + PerfectMySQL + PerfectLogger .
+Open the Posts page to try it.
+
+Web routes
+
+ Method Path Description
+ GET /This page
+ GET /postsBrowser UI for the posts API
+ GET /static/**Static files from webroot/static/
+
+
+API routes — /api
+
+ Method Path Description
+ GET /api/healthServer health check
+ GET /api/postsList all posts
+ POST /api/postsCreate a post
+ GET /api/posts/:idGet one post
+ PUT /api/posts/:idUpdate a post (partial)
+ DELETE /api/posts/:idDelete a post
+
+
+Quick start
+# MySQL in a container, on host port 13306 (the app's default DB_PORT)
+docker run -d --name perfect-template-mysql -p 13306:3306 \
+ -e MYSQL_ROOT_PASSWORD=change-me -e MYSQL_DATABASE=perfect_template \
+ -e MYSQL_USER=perfect -e MYSQL_PASSWORD=perfect mysql:8.4
+
+# MySQL client library (macOS; on Linux: apt-get install libmysqlclient-dev pkg-config)
+brew install mysql-client pkgconf
+export PKG_CONFIG_PATH="$(brew --prefix mysql-client)/lib/pkgconfig"
+
+# Build and run from the package root
+swift run
+
+Try the API
+# Health check
+curl http://localhost:9191/api/health
+
+# Create a post
+curl -X POST http://localhost:9191/api/posts \
+ -H "Content-Type: application/json" \
+ -d '{"title":"Hello","body":"World"}'
+
+# List posts
+curl http://localhost:9191/api/posts
+
+
+ Override defaults with environment variables, for example
+ PORT=9090 DB_HOST=db.internal DB_PORT=3306 DB_USER=app DB_PASS=secret swift run.
+ The README lists them all.
+
+
+
+
diff --git a/webroot/posts.html b/webroot/posts.html
new file mode 100644
index 0000000..43cf46e
--- /dev/null
+++ b/webroot/posts.html
@@ -0,0 +1,312 @@
+
+
+
+
+
+ Posts — PerfectTemplate
+
+
+
+
+
+
+
+
+
+
Loading…
+ + New Post
+
+
+
+
+
+
+
+
+ Title
+ Body
+ Created
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webroot/static/favicon.svg b/webroot/static/favicon.svg
new file mode 100644
index 0000000..b0b8819
--- /dev/null
+++ b/webroot/static/favicon.svg
@@ -0,0 +1 @@
+