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) - -

- - Get Involed with Perfect! - -

- -

- - Star Perfect On Github - - - Stack Overflow - - - Follow Perfect on Twitter - - - Join the Perfect Slack - -

- -

- - Swift 5.0 - - - Platforms macOS | Linux - - - License Apache - - - PerfectlySoft Twitter - - - Slack Status - -

- -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) - -

- - Get Involed with Perfect! - -

- -

- - Star Perfect On Github - - - Stack Overflow - - - Follow Perfect on Twitter - - - Join the Perfect Slack - -

- -

- - Swift 4.0 - - - Platforms OS X | Linux - - - License Apache - - - PerfectlySoft Twitter - - - Slack Status - -

- -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

+ + + + + +
MethodPathDescription
GET/This page
GET/postsBrowser UI for the posts API
GET/static/**Static files from webroot/static/
+ +

API routes — /api

+ + + + + + + + +
MethodPathDescription
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 + + + + + +
+ ← Home +

Posts

+
+ +
+
+

Loading…

+ +
+ +
+ +
+ + + + + + + + + + + + +
TitleBodyCreated
+
+
+ + +
+ +
+ + + + 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 @@ +