-
-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
-```
-git clone https://github.com/PerfectlySoft/PerfectTemplate.git
-cd PerfectTemplate
+A reference application demonstrating this ecosystem end to end. It's a small blog-style "Posts"
+CRUD web app that wires together **Perfect-NIO**, **Perfect-CRUD**, **Perfect-MySQL**, and
+**Perfect-Logger** — JSON API, MySQL persistence, structured logging, and a static browser UI, all
+in one place. Nothing else here depends on this repo; it exists to demonstrate the four libraries
+working together and as a starting point for new apps built on them.
+
+The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch.
+
+## Requirements
+
+- **Swift tools version:** 6.2
+- **Platform:** macOS 12+ (`.macOS(.v12)`) — no Linux or other platform target is currently declared
+- A reachable **MySQL** server (the app starts even if MySQL is unreachable, but database-backed routes will error until a connection is available)
+
+## Dependencies
+
+| Package | Products used |
+|---|---|
+| [Perfect-NIO](https://github.com/PerfectlySoft/Perfect-NIO) | `PerfectNIO`, `PerfectNIOCRUD` |
+| [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) | `PerfectCRUD` |
+| [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) | `PerfectMySQL` |
+| [Perfect-Logger](https://github.com/PerfectlySoft/Perfect-Logger) | `PerfectLogger` |
+
+## What it does
+
+- **`main.swift`** bootstraps `PerfectLogger` (console + rolling file), runs schema setup for the `posts` MySQL table (non-fatal if MySQL is down), builds routes, and starts a `PerfectNIO` `Server` on `Config.port`.
+- **API routes** (`Sources/App/Routes/APIRoutes.swift`), backed by a MySQL-persisted `Post` model via PerfectCRUD:
+ - `GET /api/health` — health check, no database
+ - `GET /api/posts` — list all posts
+ - `POST /api/posts` — create a post
+ - `GET /api/posts/:id` — get one post
+ - `PUT /api/posts/:id` — partial update
+ - `DELETE /api/posts/:id` — delete a post
+ - `GET /api/dbsetup` — re-run schema setup
+- **Web routes** (`Sources/App/Routes/WebRoutes.swift`) serve `webroot/index.html`, `webroot/posts.html` (a browser CRUD UI), and `webroot/static/**`.
+
+## Configuration
+
+Everything is environment-variable driven with sane defaults (see `Sources/App/Config.swift`):
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `PORT` | `9191` | HTTP server port |
+| `DB_HOST` | `127.0.0.1` | MySQL host |
+| `DB_NAME` | `perfect_template` | MySQL database name |
+| `DB_USER` | `root` | MySQL user |
+| `DB_PASS` | *(empty)* | MySQL password |
+| `LOG_FILE` | `./perfect-template.log` | Rolling log file path |
+| `LOG_LEVEL` | `info` | `debug` / `info` / `warning` / `error` / `critical` |
+
+## Running
+
+```sh
swift run
```
-You should see the following output:
+Or with overrides:
-```
-[INFO] Starting HTTP server localhost on 0.0.0.0:8181
+```sh
+PORT=9090 DB_NAME=myapp DB_USER=root LOG_LEVEL=debug 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)])
+Then visit `http://localhost:9191` (or your configured `PORT`) for the browser UI, or hit the `/api/*` routes directly.
-```
+## Status
-## Further Information
-For more information on the Perfect project, please visit [perfect.org](http://perfect.org).
+This is a small, currently-maintained reference app — not an abandoned scaffold. It's kept in sync with the current APIs of its four dependencies (Perfect-NIO, Perfect-CRUD, Perfect-MySQL, Perfect-Logger) as they evolve.
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/Sources/App/Config.swift b/Sources/App/Config.swift
new file mode 100644
index 0000000..eb10a77
--- /dev/null
+++ b/Sources/App/Config.swift
@@ -0,0 +1,31 @@
+import Foundation
+import PerfectLogger
+
+/// App configuration sourced from environment variables with sane defaults.
+/// Set these before launching or pass them via the shell:
+/// PORT=9090 DB_NAME=myapp DB_USER=root LOG_LEVEL=debug swift run
+enum Config {
+ static let port = env("PORT").flatMap(Int.init) ?? 9191
+ static let dbHost = env("DB_HOST") ?? "127.0.0.1"
+ static let dbName = env("DB_NAME") ?? "perfect_template"
+ static let dbUser = env("DB_USER") ?? "root"
+ static let dbPass = env("DB_PASS") ?? ""
+
+ /// Path the file log handler appends to. Default: ./perfect-template.log
+ static let logFile = env("LOG_FILE") ?? "./perfect-template.log"
+
+ /// Minimum log level for all handlers. Set LOG_LEVEL=debug for verbose output.
+ static let logLevel: LogPriority = {
+ switch (env("LOG_LEVEL") ?? "info").lowercased() {
+ case "debug": return .debug
+ case "warning", "warn": return .warning
+ case "error": return .error
+ case "critical": return .critical
+ default: return .info
+ }
+ }()
+
+ private static func env(_ key: String) -> String? {
+ ProcessInfo.processInfo.environment[key]
+ }
+}
diff --git a/Sources/App/DB.swift b/Sources/App/DB.swift
new file mode 100644
index 0000000..a695433
--- /dev/null
+++ b/Sources/App/DB.swift
@@ -0,0 +1,27 @@
+import PerfectCRUD
+import PerfectMySQL
+import PerfectLogger
+
+/// Creates a new database connection per call. Safe to call from any thread or task.
+func makeDB() throws -> Database {
+ let config = try MySQLDatabaseConfiguration(
+ database: Config.dbName,
+ host: Config.dbHost,
+ username: Config.dbUser,
+ password: Config.dbPass
+ )
+ return Database(configuration: config)
+}
+
+/// Creates the posts table if it doesn't exist. Called once at startup.
+func setupSchema() -> String {
+ do {
+ let db = try makeDB()
+ try db.create(Post.self, policy: .shallow)
+ LogFile.info("database schema ready")
+ return "Database schema ready."
+ } catch {
+ LogFile.error("schema setup failed — \(error); server will start, database routes will fail until MySQL is reachable")
+ return "Warning: schema setup failed — \(error)"
+ }
+}
diff --git a/Sources/App/Models/Post.swift b/Sources/App/Models/Post.swift
new file mode 100644
index 0000000..80dda11
--- /dev/null
+++ b/Sources/App/Models/Post.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+struct Post: Codable, Sendable {
+ var id: String
+ var title: String
+ var body: String
+ var createdAt: String
+}
+
+struct CreatePostRequest: Codable, Sendable {
+ var title: String
+ var body: String
+}
+
+struct UpdatePostRequest: Codable, Sendable {
+ var title: String?
+ var body: String?
+}
diff --git a/Sources/App/Routes/APIRoutes.swift b/Sources/App/Routes/APIRoutes.swift
new file mode 100644
index 0000000..70ac12f
--- /dev/null
+++ b/Sources/App/Routes/APIRoutes.swift
@@ -0,0 +1,102 @@
+import Foundation
+import PerfectNIO
+import PerfectCRUD
+import PerfectMySQL
+import PerfectLogger
+
+/// All routes under /api
+///
+/// GET /api/health — server health check, no database
+/// GET /api/posts — list all posts
+/// 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
+func apiRoutes() throws -> Routes {
+
+ // ── Health ────────────────────────────────────────────────────────────────
+
+ let health = root().GET.api.health.map { _ -> HTTPOutput in
+ let body: [String: String] = [
+ "status": "ok",
+ "time": ISO8601DateFormatter().string(from: Date()),
+ ]
+ return try JSONOutput(body)
+ }
+
+ // ── Posts: collection ─────────────────────────────────────────────────────
+
+ let listPosts = root().GET.api.posts.map { _ -> HTTPOutput in
+ let posts = try makeDB().table(Post.self).select().map { $0 }
+ return try JSONOutput(posts)
+ }
+
+ // decode() reads the JSON request body and passes it alongside the HTTPRequest.
+ let createPost = root().POST.api.posts.decode(CreatePostRequest.self) { _, input -> HTTPOutput in
+ let post = Post(
+ id: UUID().uuidString,
+ title: input.title,
+ body: input.body,
+ createdAt: ISO8601DateFormatter().string(from: Date())
+ )
+ // The returned event id correlates every subsequent log line for this request.
+ let eid = LogFile.info("creating post \"\(post.title)\"")
+ do {
+ try makeDB().table(Post.self).insert(post)
+ } catch {
+ LogFile.error("failed to create post: \(error)", eventid: eid)
+ throw error
+ }
+ LogFile.info("created post id=\(post.id)", eventid: eid)
+ // Return 201 Created with the new post.
+ return try JSONOutput(post, head: HTTPHead(status: .created, headers: HTTPHeaders()))
+ }
+
+ // ── Posts: single resource ────────────────────────────────────────────────
+
+ // wild() captures the next path segment as a String.
+ let getPost = root().GET.api.posts.wild { _, id -> HTTPOutput in
+ let db = try makeDB()
+ guard let post = try db.table(Post.self).where(\Post.id == id).select().map({ $0 }).first else {
+ LogFile.warning("post not found id=\(id)")
+ throw ErrorOutput(status: .notFound, description: "No post with id: \(id)")
+ }
+ return try JSONOutput(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")
+ .decode(UpdatePostRequest.self) { req, input -> HTTPOutput in
+ guard let id = req.uriVariables["id"] else {
+ throw ErrorOutput(status: .badRequest, description: "Missing post id")
+ }
+ let db = try makeDB()
+ guard var post = try db.table(Post.self).where(\Post.id == id).select().map({ $0 }).first else {
+ throw ErrorOutput(status: .notFound, description: "No post with id: \(id)")
+ }
+ if let title = input.title { post.title = title }
+ if let body = input.body { post.body = body }
+ try db.table(Post.self).where(\Post.id == id).update(post)
+ return try JSONOutput(post)
+ }
+
+ let deletePost = root().DELETE.api.posts.wild { _, id -> HTTPOutput in
+ do {
+ try makeDB().table(Post.self).where(\Post.id == id).delete()
+ } catch {
+ LogFile.error("failed to delete post id=\(id): \(error)")
+ throw error
+ }
+ LogFile.info("deleted post id=\(id)")
+ return try JSONOutput(["deleted": id])
+ }
+ let dbSetup = root().GET.api.dbsetup.map { _ -> HTTPOutput in
+ let schemaResult = setupSchema()
+
+ return try JSONOutput([schemaResult])
+ }
+
+ return try root().dir(health, listPosts, createPost, getPost, updatePost, deletePost, dbSetup)
+}
diff --git a/Sources/App/Routes/WebRoutes.swift b/Sources/App/Routes/WebRoutes.swift
new file mode 100644
index 0000000..1ebb157
--- /dev/null
+++ b/Sources/App/Routes/WebRoutes.swift
@@ -0,0 +1,17 @@
+import PerfectNIO
+
+/// GET / → serves webroot/index.html
+/// GET /posts → serves webroot/posts.html (CRUD UI)
+/// GET /static/** → serves any file under webroot/static/
+func webRoutes() throws -> Routes {
+ let index = root().GET.map { _ -> HTTPOutput in
+ try FileOutput(localPath: "webroot/index.html")
+ }
+ let postsPage = root().GET.path("posts").map { _ -> HTTPOutput in
+ try FileOutput(localPath: "webroot/posts.html")
+ }
+ let staticFiles = root().GET.path("static").trailing { _, path -> HTTPOutput in
+ try FileOutput(localPath: "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..d6ea765
--- /dev/null
+++ b/Sources/App/main.swift
@@ -0,0 +1,29 @@
+import Foundation
+import PerfectNIO
+import PerfectLogger
+
+// Configure logging first: console + a rolling 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))")
+
+// Set up the database schema on startup. If MySQL is unreachable the server
+// still starts — database routes will return errors until a connection is available.
+let _ = setupSchema()
+
+let routes = try allRoutes()
+LogFile.info("serving on http://localhost:\(Config.port)")
+try await Server(routes: routes, port: Config.port).run()
+
+// ── 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..5de8924
--- /dev/null
+++ b/webroot/index.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+ PerfectTemplate
+
+
+
+
+
PerfectTemplate
+
A Swift 6 starter built on PerfectNIO + PerfectCRUD + PerfectMySQL.
+
+
Web routes
+
+
Method
Path
Description
+
GET
/
This page
+
GET
/static/**
Static files from webroot/static/
+
+
+
API routes — /api
+
+
Method
Path
Description
+
GET
/api/health
Server health check
+
GET
/api/posts
List all posts
+
POST
/api/posts
Create a post
+
GET
/api/posts/:id
Get one post
+
PUT
/api/posts/:id
Update a post (partial)
+
DELETE
/api/posts/:id
Delete a post
+
+
+
Quick start
+
# Install MySQL client (macOS)
+brew install mysql-client pkg-config
+export PKG_CONFIG_PATH="/opt/homebrew/opt/mysql-client/lib/pkgconfig:$PKG_CONFIG_PATH"
+
+# Create the database
+mysql -u root -e "CREATE DATABASE IF NOT EXISTS perfect_template;"
+
+# Build and run
+PKG_CONFIG_PATH=/opt/homebrew/opt/mysql-client/lib/pkgconfig swift run
+
+
Try the API
+
# Health check
+curl http://localhost:8080/api/health
+
+# Create a post
+curl -X POST http://localhost:8080/api/posts \
+ -H "Content-Type: application/json" \
+ -d '{"title":"Hello","body":"World"}'
+
+# List posts
+curl http://localhost:8080/api/posts
+
+
+ Override defaults with environment variables:
+ PORT=9090 DB_NAME=myapp DB_USER=app DB_PASS=secret swift run
+