From 0843ee9a98e0fb328c0b9015c9bf601ca81346e8 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 1 Sep 2026 10:23:26 -0500 Subject: [PATCH] Swift 6 modernization: rewrite as PerfectNIO/CRUD/MySQL/Logger reference app Replaces the old single-file Perfect 3-era starter (Sources/PerfectTemplate/main.swift) with a full Posts CRUD reference app on the modernized stack. LICENSE/LICENSE.zh_CN carried forward unchanged from the original; README.zh_CN.md dropped since it would otherwise be left silently out of sync with the fully rewritten English README. --- .gitignore | 75 +------- Package.resolved | 195 +++++++++++++++++++ Package.swift | 38 ++-- README.md | 155 ++++++--------- README.zh_CN.md | 155 --------------- Sources/App/Config.swift | 31 +++ Sources/App/DB.swift | 27 +++ Sources/App/Models/Post.swift | 18 ++ Sources/App/Routes/APIRoutes.swift | 102 ++++++++++ Sources/App/Routes/WebRoutes.swift | 17 ++ Sources/App/main.swift | 29 +++ Sources/PerfectTemplate/main.swift | 47 ----- webroot/index.html | 77 ++++++++ webroot/posts.html | 294 +++++++++++++++++++++++++++++ 14 files changed, 881 insertions(+), 379 deletions(-) create mode 100644 Package.resolved delete mode 100644 README.zh_CN.md create mode 100644 Sources/App/Config.swift create mode 100644 Sources/App/DB.swift create mode 100644 Sources/App/Models/Post.swift create mode 100644 Sources/App/Routes/APIRoutes.swift create mode 100644 Sources/App/Routes/WebRoutes.swift create mode 100644 Sources/App/main.swift delete mode 100644 Sources/PerfectTemplate/main.swift create mode 100644 webroot/index.html create mode 100644 webroot/posts.html 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..dd3cab2 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,195 @@ +{ + "originHash" : "5d2713448601b68395bde1a0df163422d8067e01841017f3d59901d18cb3745c", + "pins" : [ + { + "identity" : "perfect-crud", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PerfectlySoft/Perfect-CRUD.git", + "state" : { + "branch" : "main", + "revision" : "110ab8077951c5ab7841db42cb643698d5f2d9c9" + } + }, + { + "identity" : "perfect-logger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PerfectlySoft/Perfect-Logger.git", + "state" : { + "branch" : "main", + "revision" : "f2c99dcf3952efea3c6aede10538127fbebb6cc7" + } + }, + { + "identity" : "perfect-mysql", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PerfectlySoft/Perfect-MySQL.git", + "state" : { + "branch" : "main", + "revision" : "a85b6eb88fb2e5bc04d1ef87ef465cb1030dc6c8" + } + }, + { + "identity" : "perfect-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PerfectlySoft/Perfect-NIO.git", + "state" : { + "branch" : "main", + "revision" : "441b87e36230ce322a32348e957bbfe03d505f23" + } + }, + { + "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" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f", + "version" : "1.7.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "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" : "c8aece90ea05f9866bd392a5bf13b5cae56c0e03", + "version" : "1.20.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" + } + }, + { + "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" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "a931f2c1de8dd49381ce3bf2e279d033f68d8865", + "version" : "2.102.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3078359149adac3dd1255621a041e361e3f0edd1", + "version" : "1.35.0" + } + }, + { + "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" : "dea6ebb773fb4b140753d48f93c8d0084531fbd5", + "version" : "2.37.3" + } + }, + { + "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" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.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..b8e68d8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,16 +1,30 @@ -// 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", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MySQL.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-Logger.git", branch: "main"), + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "PerfectNIO", package: "Perfect-NIO"), + .product(name: "PerfectNIOCRUD", package: "Perfect-NIO"), + .product(name: "PerfectCRUD", package: "Perfect-CRUD"), + .product(name: "PerfectMySQL", package: "Perfect-MySQL"), + .product(name: "PerfectLogger", package: "Perfect-Logger"), + ] + ) + ] ) diff --git a/README.md b/README.md index 3bfed6f..73fa3d7 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,69 @@ -# 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 -``` -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) - -

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

+ + + + +
MethodPathDescription
GET/This page
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

+
# 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 +
+ + + diff --git a/webroot/posts.html b/webroot/posts.html new file mode 100644 index 0000000..c17a2ae --- /dev/null +++ b/webroot/posts.html @@ -0,0 +1,294 @@ + + + + + + Posts — PerfectTemplate + + + + +
+ ← Home +

Posts

+
+ +
+
+

Loading…

+ +
+ +
+ +
+ + + + + + + + + + + + +
TitleBodyCreated
+
+
+ + +
+ +
+ + + +