From ddcc09e170dccf21a45223b768ef5980c6525ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Mon, 17 Apr 2023 23:51:26 +0200 Subject: [PATCH 01/18] Use next-less API in comments ... --- Sources/express/Express.swift | 4 ++-- Sources/express/README.md | 4 ++-- Sources/express/Render.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/express/Express.swift b/Sources/express/Express.swift index 2a92782..b90a142 100644 --- a/Sources/express/Express.swift +++ b/Sources/express/Express.swift @@ -3,7 +3,7 @@ // Noze.io / ExExpress / Macro // // Created by Helge Heß on 6/2/16. -// Copyright © 2016-2022 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2023 ZeeZide GmbH. All rights reserved. // import struct Logging.Logger @@ -37,7 +37,7 @@ import class http.ServerResponse * * let app = Express() * app.use("/index") { - * req, res, _ in res.render("index") + * req, res in res.render("index") * } * * diff --git a/Sources/express/README.md b/Sources/express/README.md index 37bad3b..e0382b5 100644 --- a/Sources/express/README.md +++ b/Sources/express/README.md @@ -49,10 +49,10 @@ app.use { req, _, next in // Mustache template in views/form.html. // - If the browser sends 'POST /form', grab the values and render the // form with them -app.get("/form") { _, res, _ in +app.get("/form") { _, res in res.render("form") } -app.post("/form") { req, res, _ in +app.post("/form") { req, res in let user = req.body[string: "user"] // form value 'user' let options : [ String : Any ] = [ diff --git a/Sources/express/Render.swift b/Sources/express/Render.swift index 2a56e33..b34217c 100644 --- a/Sources/express/Render.swift +++ b/Sources/express/Render.swift @@ -3,7 +3,7 @@ // Noze.io / Macro / ExExpress // // Created by Helge Heß on 6/2/16. -// Copyright © 2016-2020 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2023 ZeeZide GmbH. All rights reserved. // import enum MacroCore.process @@ -29,7 +29,7 @@ public extension ServerResponse { * * Example: * - * app.get { _, res, _ in + * app.get { _, res in * res.render('index', { "title": "Hello World!" }) * } * From 2c33cd17258f0f2ee2fe647410e83973825f2daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sat, 22 Apr 2023 16:54:14 +0200 Subject: [PATCH 02/18] More bodyParser documentation ... --- Sources/connect/BodyParser.swift | 221 ++++++++++++++++++++++++------- 1 file changed, 176 insertions(+), 45 deletions(-) diff --git a/Sources/connect/BodyParser.swift b/Sources/connect/BodyParser.swift index b9d36a4..5a5e5cb 100644 --- a/Sources/connect/BodyParser.swift +++ b/Sources/connect/BodyParser.swift @@ -3,7 +3,7 @@ // Noze.io / MacroExpress // // Created by Helge Heß on 30/05/16. -// Copyright © 2016-2021 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2023 ZeeZide GmbH. All rights reserved. // import MacroCore // for `|` operator @@ -13,27 +13,81 @@ import protocol MacroCore.EnvironmentKey import func MacroCore.concat import enum http.querystring -/// An enum which stores the result of the `bodyParser` middleware. The result -/// can be accessed as `request.body`, e.g. -/// -/// if case .JSON(let json) = request.body { -/// // do JSON stuff -/// } -/// +/** + * An enum which stores the result of the ``bodyParser`` middleware. + * + * The parsing result enum can be accessed using the ``IncomingMessage/body`` + * property: + * ``` + * if case .JSON(let json) = request.body { + * // do JSON stuff + * } + * ``` + * + * The enum has a set of convenience helper properties/functions to access the + * body using the expected format, e.g.: + * - `json`: e.g. `if let json = request.body.json as? [ String : Any ] {}` + * - `text`: e.g. `if let text = request.body.text {}` + * + * Those things "coerce", e.g. one can access a body that was transfered + * URL encoded as "JSON". + * + * If the body is structured, keys can be looked up directly on the body, + * e.g. if the body is JSON like this (or similar URL encoded): + * ```json + * { "answer": 42, "years": [ 1973, 1976 ] } + * ``` + * It can be retrieved like: + * ``` + * request.body.answer as? Int + * request.body.count // 2 + * request.body.isEmpty // false + * ``` + * + * It also provides a set of subscripts: + * ``` + * request.body["answer"] // 42 (`Any?`) + * request.body[int: "answer"] // 42 (`Int?`) + * request.body[string: "answer"] // "42" (`String`) + * ``` + */ @dynamicMemberLookup public enum BodyParserBody { + /// The request has not been parsed yet by the ``bodyParser`` middleware. case notParsed + + /// The request doesn't contain a body. case noBody // IsPerfect + + /// An error occurred while parsing the body. case error(Swift.Error) + /// The body was URL encoded, the associated value is the pair of URL encoded + /// parameters. case urlEncoded([ String : Any ]) + /// The body was decoded as JSON, the associated value contains the + /// JSON structure. case json(Any) + /// The body was decoded as raw bytes. case raw(Buffer) + + /// The body was decoded as text and could be converted to a Swift String. case text(String) + /** + * Lookup a value of a key/value based format directly on the `body`, + * e.g. if the body is JSON like this: + * ```json + * { "answer": 42 } + * ``` + * It can be retrieved like: + * ``` + * if let answer = request.body.answer as? Int {} + * ``` + */ @inlinable public subscript(dynamicMember k: String) -> Any? { return self[k] @@ -42,6 +96,12 @@ public enum BodyParserBody { public extension BodyParserBody { + /** + * Returns the body as basic "JSON types", i.e. strings, dicts, arrays etc. + * + * It is not actually limited to JSON, but also returns a value for `text` + * and `urlEncoded` bodies. + */ @inlinable var json: Any? { switch self { @@ -76,6 +136,17 @@ public extension BodyParserBody { public extension BodyParserBody { + /** + * Returns whether the body is "empty". + * + * It is considered empty if: + * - it hasn't been parsed yet, had no body, or there was an error + * - if it was URL encoded and the resulting dictionary is empty + * - if it was JSON and the resulting `[String:Any]` dictionary or `[Any]` + * array was emtpy (returns false for all other content). + * - if the body was raw data and that's empty + * - if the body was a String and that's empty + */ @inlinable var isEmpty: Bool { switch self { @@ -92,6 +163,18 @@ public extension BodyParserBody { } } + /** + * Returns whether the number of top-level items in the body. + * + * - Returns 0 if it hasn't been parsed yet, had no body, or there was an + * error. + * - If it was URL encoded, returns the number of items in the decoded + * dictionary. + * - If it was JSON and the result was a `[String:Any]` dictionary or `[Any]` + * array, the count of that, otherwise 1. + * - The number of bytes in a raw data body. + * - The number of characters in a Strign body. + */ @inlinable var count: Int { switch self { @@ -111,6 +194,11 @@ public extension BodyParserBody { public extension BodyParserBody { + /** + * Lookup the value for a key in either a URL encoded dictionary, + * or in a `[ String : Any ]` JSON dictionary. + * Returns `nil` for everything else. + */ @inlinable subscript(key: String) -> Any? { switch self { @@ -125,6 +213,14 @@ public extension BodyParserBody { } } + /** + * Lookup the value for a key in either a URL encoded dictionary, + * or in a `[ String : Any ]` JSON dictionary, + * and convert that to a String. + * Returns an empty String if the key was not found, + * the value if it was a String already, + * otherwise the CustomStringConvertible or system description. + */ @inlinable subscript(string key: String) -> String { get { @@ -136,8 +232,14 @@ public extension BodyParserBody { } /** - * Lookup the given key in either URL parameters or JSON and try to - * coerce it to an Int. + * Lookup the value for a key in either a URL encoded dictionary, + * or in a `[ String : Any ]` JSON dictionary, + * and convert that to an `Int`, if possible.. + * Returns `nil` if the key was not found, + * the value if it was an `Int` / `Int64` already, + * the `Int(double)` value for a `Double`, + * and the `Int(string)` parse result for a `String`. + * Or `nil` for all other types. */ @inlinable subscript(int key: String) -> Int? { @@ -202,14 +304,17 @@ extension BodyParserBody : CustomStringConvertible { extension BodyParserBody : ExpressibleByStringLiteral { + /// Create a `text` body. @inlinable public init(stringLiteral value: String) { self = .text(value) } + /// Create a `text` body. @inlinable public init(extendedGraphemeClusterLiteral value: StringLiteralType) { self = .text(value) } + /// Create a `text` body. @inlinable public init(unicodeScalarLiteral value: StringLiteralType) { self = .text(value) @@ -224,9 +329,9 @@ public enum bodyParser { * Options for use in request body parsers. */ public class Options { - let inflate = false - let limit = 100 * 1024 - let extended = true + public var inflate = false + public var limit = 100 * 1024 + public var extended = true @inlinable public init() {} @@ -249,6 +354,24 @@ public enum BodyParserError : Error { public extension IncomingMessage { + /** + * Returns the ``BodyParserBody`` associated with the request, + * i.e. the result of the ``bodyParser`` middleware. + * If the middleware wasn't invoked, this will return + * ``BodyParserBody/notParsed`` + * + * There is a set of convenience helpers to deal with the result: + * ``` + * request.json // "JSON" types wrapped in `Any` + * request.text // "Hello" + * request.body.answer as? Int + * request.body.count // 2 + * request.body.isEmpty // false + * request.body["answer"] // 42 (`Any?`) + * request.body[int: "answer"] // 42 (`Int?`) + * request.body[string: "answer"] // "42" (`String`) + * ``` + */ var body: BodyParserBody { set { environment[bodyParser.BodyKey.self] = newValue } get { return environment[bodyParser.BodyKey.self] } @@ -264,18 +387,20 @@ public extension IncomingMessage { public extension bodyParser { - /// This middleware parses the request body if the content-type is JSON, - /// and pushes the the JSON parse result into the `body` property of the - /// request. - /// - /// Example: - /// - /// app.use(bodyParser.json()) - /// app.use { req, res, next in - /// print("Log JSON Body: \(req.body.json)") - /// next() - /// } - /// + /** This middleware parses the request body if the content-type is JSON, + * and pushes the the JSON parse result into the `body` property of the + * request. + * + * Example: + * ``` + * app.use(bodyParser.json()) // loads and parses the request + * app.use { req, res, next in + * console.log("Log JSON Body:", req.body.json) + * console.log("Answer:", req.body.answer) + * next() + * } + * ``` + */ static func json(options opts: Options = Options()) -> Middleware { return { req, res, next in @@ -332,7 +457,7 @@ private func concatError(request : IncomingMessage, next : @escaping Next, handler : @escaping ( Buffer ) -> Swift.Error?) { - var didCallNext = false + var didCallNext = false // used to share the error state request | concat { bytes in guard !didCallNext else { return } @@ -361,14 +486,16 @@ public extension bodyParser { * Note: Make sure to place this middleware behind other middleware parsing * more specific content types! * - * # Usage + * ## Usage * - * app.use(bodyParser.raw()) + * ``` + * app.use(bodyParser.raw()) // load the content, similar to `concat` * - * app.post("/post") { req, res, next in - * console.log("Request body is:", req.body) - * next() - * } + * app.post("/post") { req, res, next in + * console.log("Request body is:", req.body) + * next() + * } + * ``` * * - Parameter options: The options to be used for parsing. * - Returns: A middleware which does the parsing as described. @@ -405,14 +532,15 @@ public extension bodyParser { * Note: Make sure to place this middleware behind other middleware parsing * more specific content types! * - * # Usage - * - * app.use(bodyParser.text()) + * ## Usage + * ``` + * app.use(bodyParser.text()) // load and parse the request * - * app.post("/post") { req, res, next in - * console.log("Request text is:", req.text) - * next() - * } + * app.post("/post") { req, res, next in + * console.log("Request text is:", req.text) + * next() + * } + * ``` * * - Parameter options: The options to be used for parsing. * - Returns: A middleware which does the parsing as described. @@ -475,14 +603,17 @@ public extension bodyParser { * The results of the parsing are available using the `request.body` enum. * If the parsing fails, that will be set to the `.error` case. * - * # Usage + * ## Usage * - * app.use(bodyParser.urlencoded()) + * ``` + * app.use(bodyParser.urlencoded()) // load an parse the request * - * app.post("/post") { req, res, next in - * console.log("Query is:", req.body[string: "query"]) - * next() - * } + * app.post("/post") { req, res, next in + * console.log("Query is:", req.body[string: "query"]) + * console.log("Query is:", req.body.query) + * next() + * } + * ``` * * - Parameter options: The options to be used for parsing. Use the `extended` * setting to enable the use of `qs.parse`. From 608c9d847b78896b0b2bf3ad8842c5a5aa306a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sat, 22 Apr 2023 17:03:12 +0200 Subject: [PATCH 03/18] Honor the `limit` in the bodyParser Pass it along to `concat`, so that only `limit` number of bytes are loaded into memory. --- Sources/connect/BodyParser.swift | 43 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/Sources/connect/BodyParser.swift b/Sources/connect/BodyParser.swift index 5a5e5cb..c6432c1 100644 --- a/Sources/connect/BodyParser.swift +++ b/Sources/connect/BodyParser.swift @@ -329,12 +329,34 @@ public enum bodyParser { * Options for use in request body parsers. */ public class Options { - public var inflate = false - public var limit = 100 * 1024 - public var extended = true - + + /// Whether the body should be decompressed. + /// Unsupported yet. + public let inflate = false + + /// The maximum number of bytes that will be loaded into memory. + /// Defaults to just 100kB, must be explicitly set if larger + /// bodies are allowed! (also consider using multer). + public var limit : Int + + /// If set, `qs.parse` is used to parse URL parameters, otherwise + /// `querystring.parse` is used. + public var extended : Bool + + /** + * Setup ``bodyParser`` options. + * + * - Parameters: + * - limit: The maximum number of bytes that will be loaded into memory + * (defaults to just 100kB, explictly set for larger bodies!). + * - extended: Whether to use `qs.parse` or `querystring.parse` for + * URL encoded parameters. + */ @inlinable - public init() {} + public init(limit: Int = 100_000, extended: Bool = true) { + self.limit = limit + self.extended = extended + } } fileprivate enum BodyKey: EnvironmentKey { @@ -425,7 +447,7 @@ public extension bodyParser { case .notParsed: // lame, should be streaming - concatError(request: req, next: next) { bytes in + concatError(request: req, limit: opts.limit, next: next) { bytes in setBodyIfNotNil(JSONModule.parse(bytes)) return nil } @@ -454,12 +476,13 @@ public extension bodyParser { // state private func concatError(request : IncomingMessage, + limit : Int, next : @escaping Next, handler : @escaping ( Buffer ) -> Swift.Error?) { var didCallNext = false // used to share the error state - request | concat { bytes in + request | concat(maximumSize: limit) { bytes in guard !didCallNext else { return } if let error = handler(bytes) { next(error) @@ -507,7 +530,7 @@ public extension bodyParser { return next() // already loaded case .notParsed: - concatError(request: req, next: next) { bytes in + concatError(request: req, limit: opts.limit, next: next) { bytes in req.body = .raw(bytes) return nil } @@ -556,7 +579,7 @@ public extension bodyParser { return next() // already loaded case .notParsed: - concatError(request: req, next: next) { bytes in + concatError(request: req, limit: opts.limit, next: next) { bytes in do { req.body = .text(try bytes.toString()) return nil @@ -630,7 +653,7 @@ public extension bodyParser { return next() // already loaded case .notParsed: - concatError(request: req, next: next) { bytes in + concatError(request: req, limit: opts.limit, next: next) { bytes in do { let s = try bytes.toString() let qp = opts.extended ? qs.parse(s) : querystring.parse(s) From 668b3fb26fcd82f3bde6e1f7d4987f2da4224a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sat, 3 Feb 2024 14:51:52 +0100 Subject: [PATCH 04/18] Update README.md --- README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3e3f937..a108a03 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,8 @@ app.listen(1337) ### Who **MacroExpress** is brought to you by -the -[Always Right Institute](http://www.alwaysrightinstitute.com) -and -[ZeeZide](http://zeezide.de). -We like -[feedback](https://twitter.com/ar_institute), -GitHub stars, -cool [contract work](http://zeezide.com/en/services/services.html), +[Helge Heß](https://github.com/helje5/) / [ZeeZide](https://zeezide.de). +We like feedback, GitHub stars, cool contract work, presumably any form of praise you can think of. There is a `#microexpress` channel on the From affa6149229ec7d35154806c39f411504be2d39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 16:51:05 +0200 Subject: [PATCH 05/18] Add support for `type` option Allows the user to override the type check of a specific bodyParser. --- Sources/connect/BodyParser.swift | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Sources/connect/BodyParser.swift b/Sources/connect/BodyParser.swift index c6432c1..c3f156e 100644 --- a/Sources/connect/BodyParser.swift +++ b/Sources/connect/BodyParser.swift @@ -342,7 +342,11 @@ public enum bodyParser { /// If set, `qs.parse` is used to parse URL parameters, otherwise /// `querystring.parse` is used. public var extended : Bool - + + /// If set, this is used to check whether a bodyParser should run for a + /// given request. + public var type : (( IncomingMessage ) -> Bool)? + /** * Setup ``bodyParser`` options. * @@ -351,11 +355,16 @@ public enum bodyParser { * (defaults to just 100kB, explictly set for larger bodies!). * - extended: Whether to use `qs.parse` or `querystring.parse` for * URL encoded parameters. + * - type: Override the default MIME type of the request that is being + * checked. */ @inlinable - public init(limit: Int = 100_000, extended: Bool = true) { + public init(limit: Int = 100_000, extended: Bool = true, + type: String? = nil) + { self.limit = limit self.extended = extended + if let type { self.type = { typeIs($0, [ type ]) != nil } } } } @@ -365,6 +374,15 @@ public enum bodyParser { } } +fileprivate extension bodyParser.Options { + + func checkType(_ req: IncomingMessage, defaultType: String? = nil) -> Bool { + if let type { return type(req) } + if let defaultType { return typeIs(req, [ defaultType ]) != nil } + return true + } +} + public enum BodyParserError : Error { case extraStoreInconsistency @@ -426,7 +444,7 @@ public extension bodyParser { static func json(options opts: Options = Options()) -> Middleware { return { req, res, next in - guard typeIs(req, [ "json" ]) != nil else { return next() } + guard opts.checkType(req, defaultType: "json") else { return next() } struct CouldNotParseJSON: Swift.Error {} @@ -525,6 +543,7 @@ public extension bodyParser { */ static func raw(options opts: Options = Options()) -> Middleware { return { req, res, next in + guard opts.checkType(req) else { return next() } switch req.body { case .raw, .noBody, .error: return next() // already loaded @@ -572,7 +591,7 @@ public extension bodyParser { return { req, res, next in // text/plain, text/html etc // TODO: properly process charset parameter, this assumes UTF-8 - guard typeIs(req, [ "text" ]) != nil else { return next() } + guard opts.checkType(req, defaultType: "text") else { return next() } switch req.body { case .text, .noBody, .error: @@ -644,9 +663,8 @@ public extension bodyParser { */ static func urlencoded(options opts: Options = Options()) -> Middleware { return { req, res, next in - guard typeIs(req, [ "application/x-www-form-urlencoded" ]) != nil else { - return next() - } + guard opts.checkType(req, + defaultType: "application/x-www-form-urlencoded") else { return next() } switch req.body { case .urlEncoded, .noBody, .error: From 0d93e644aef51ebff1186c305d39a5b0a2496de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 16:56:06 +0200 Subject: [PATCH 06/18] Documentation for the methodOverride middleware ... --- Sources/connect/BodyParser.swift | 2 +- Sources/connect/MethodOverride.swift | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/connect/BodyParser.swift b/Sources/connect/BodyParser.swift index c3f156e..97bec38 100644 --- a/Sources/connect/BodyParser.swift +++ b/Sources/connect/BodyParser.swift @@ -3,7 +3,7 @@ // Noze.io / MacroExpress // // Created by Helge Heß on 30/05/16. -// Copyright © 2016-2023 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // import MacroCore // for `|` operator diff --git a/Sources/connect/MethodOverride.swift b/Sources/connect/MethodOverride.swift index d23f48d..9448a28 100644 --- a/Sources/connect/MethodOverride.swift +++ b/Sources/connect/MethodOverride.swift @@ -3,11 +3,30 @@ // Noze.io / Macro // // Created by Helge Heß on 5/31/16. -// Copyright © 2016-2020 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // import let MacroCore.console +/** + * Enables support for the `X-HTTP-Method-Override` header. + * + * The `X-HTTP-Method-Override` allows the client to override the actual HTTP + * method (e.g. `GET` or `PUT`) with a different one. + * I.e. the `IncomingMessage/method` property will be set to the specified + * method. + * + * This is sometimes used w/ the HTTP stack is only setup to process say `GET` + * or `POST` requests, but not something more elaborate like `MKCALENDAR`. + * + * - Parameters: + * - header: The header to check for the method override, defaults to + * `X-HTTP-Method-Override`. This header will contain the method + * name. + * - methods: The whitelisted methods that allow the override, defaults to + * just `POST`. + * - Returns: A middleware functions that applies the methodOverride. + */ public func methodOverride(header : String = "X-HTTP-Method-Override", methods : [ String ] = [ "POST" ]) -> Middleware From ba4b2fe15ed41943a53b5ad1b8e6f3c1b6ba6330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:18:30 +0200 Subject: [PATCH 07/18] Make `Int` handling a little more tolerant ... --- Sources/connect/Session.swift | 9 ++++++--- Sources/express/Express.swift | 5 ++++- Sources/express/IncomingMessage.swift | 4 ++-- Sources/express/Settings.swift | 5 ++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/connect/Session.swift b/Sources/connect/Session.swift index 03cf032..11fdd65 100644 --- a/Sources/connect/Session.swift +++ b/Sources/connect/Session.swift @@ -3,7 +3,7 @@ // Noze.io / Macro // // Created by Helge Heß on 6/16/16. -// Copyright © 2016-2020 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // import let MacroCore.console @@ -154,8 +154,11 @@ public class Session { public subscript(int key: String) -> Int { guard let v = values[key] else { return 0 } - guard let iv = v as? Int else { return 0 } - return iv + if let iv = v as? Int { return iv } + #if swift(>=5.10) + if let i = (v as? any BinaryInteger) { return Int(i) } + #endif + return Int("\(v)") ?? 0 } } diff --git a/Sources/express/Express.swift b/Sources/express/Express.swift index b90a142..bac0f8f 100644 --- a/Sources/express/Express.swift +++ b/Sources/express/Express.swift @@ -3,7 +3,7 @@ // Noze.io / ExExpress / Macro // // Created by Helge Heß on 6/2/16. -// Copyright © 2016-2023 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // import struct Logging.Logger @@ -320,6 +320,9 @@ public extension Dictionary where Key : ExpressibleByStringLiteral { subscript(int key : Key) -> Int? { guard let v = self[key] else { return nil } if let i = (v as? Int) { return i } + #if swift(>=5.10) + if let i = (v as? any BinaryInteger) { return Int(i) } + #endif return Int("\(v)") } } diff --git a/Sources/express/IncomingMessage.swift b/Sources/express/IncomingMessage.swift index f99eebc..a3e43c0 100644 --- a/Sources/express/IncomingMessage.swift +++ b/Sources/express/IncomingMessage.swift @@ -3,7 +3,7 @@ // Noze.io / Macro // // Created by Helge Heß on 6/2/16. -// Copyright © 2016-2023 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // #if canImport(Foundation) @@ -28,7 +28,7 @@ public extension IncomingMessage { * * Example: * ``` - * app.use(/users/:id/view) { req, res, next in + * app.use("/users/:id/view") { req, res, next in * guard let id = req.params[int: "id"] * else { return try res.sendStatus(400) } * } diff --git a/Sources/express/Settings.swift b/Sources/express/Settings.swift index ad9dc0e..81916d6 100644 --- a/Sources/express/Settings.swift +++ b/Sources/express/Settings.swift @@ -3,7 +3,7 @@ // Noze.io / Macro // // Created by Helge Heß on 02/06/16. -// Copyright © 2016-2021 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // /** @@ -122,6 +122,9 @@ func boolValue(_ v : Any) -> Bool { // TODO: this should be some Foundation like thing if let b = v as? Bool { return b } if let b = v as? Int { return b != 0 } + #if swift(>=5.10) + if let i = (v as? any BinaryInteger) { return Int(i) != 0 } + #endif if let s = v as? String { switch s.lowercased() { case "no", "false", "0", "disable": return false From 05f083caeb636401b8b79b1af136fc131343e01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:24:09 +0200 Subject: [PATCH 08/18] GHA: Enable latest, and 5.10 build ... --- .github/workflows/swift.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index d69b488..18d76fd 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -15,8 +15,7 @@ jobs: image: - swift:5.5.3-xenial - swift:5.6.1-bionic - - swift:5.7.2-focal - - swift:5.8-jammy + - swift:5.10.1-noble container: ${{ matrix.image }} steps: - name: Checkout Repository @@ -33,7 +32,7 @@ jobs: - name: Select latest available Xcode uses: maxim-lobanov/setup-xcode@v1.5.1 with: - xcode-version: 13.2.1 + xcode-version: latest - name: Checkout Repository uses: actions/checkout@v3 - name: Build Swift Debug Package From f2e5a5abe3d8a21564374bae827d637117175db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:32:39 +0200 Subject: [PATCH 09/18] Add and use the dynamicMemberLookup enabled dictionary Make it more JavaScript like, though it is a little edgy :-) --- Sources/express/Express.swift | 6 +- .../express/ExpressWrappedDictionary.swift | 120 ++++++++++++++++++ Sources/express/IncomingMessage.swift | 18 ++- Sources/express/Route.swift | 2 +- Sources/express/RoutePattern.swift | 2 +- Sources/express/ServerResponse.swift | 4 +- 6 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 Sources/express/ExpressWrappedDictionary.swift diff --git a/Sources/express/Express.swift b/Sources/express/Express.swift index bac0f8f..1fec3e9 100644 --- a/Sources/express/Express.swift +++ b/Sources/express/Express.swift @@ -289,7 +289,7 @@ enum ExpressExtKey { */ enum Params: EnvironmentKey { // TBD: Should the value be `Any`? - static let defaultValue : [ String : String ] = [:] + static let defaultValue = IncomingMessage.Params([:]) static let loggingKey = "params" } @@ -297,7 +297,7 @@ enum ExpressExtKey { * The query parameters as parsed by the `qs.parse` function. */ enum Query: EnvironmentKey { - static let defaultValue : [ String : Any ]? = nil + static let defaultValue : IncomingMessage.Query? = nil static let loggingKey = "query" } @@ -309,7 +309,7 @@ enum ExpressExtKey { * Traditionally `locals` was used to store Stringly-typed keys & values. */ enum Locals: EnvironmentKey { - static let defaultValue : [ String : Any ] = [:] + static let defaultValue : ServerResponse.Locals = .init([:]) static let loggingKey = "locals" } } diff --git a/Sources/express/ExpressWrappedDictionary.swift b/Sources/express/ExpressWrappedDictionary.swift new file mode 100644 index 0000000..700b76f --- /dev/null +++ b/Sources/express/ExpressWrappedDictionary.swift @@ -0,0 +1,120 @@ +// +// ExpressWrappedDictionary.swift +// MacroExpress +// +// Created by Helge Heß on 14.07.24. +// Copyright © 2024 ZeeZide GmbH. All rights reserved. +// + +/** + * This is a wrapper for dictionaries, that behaves like a dictionary, + * but also allows access to the keys using `dynamicMemberLookup`. + * + * For example: + * ```swift + * let service = request.query.service + * ``` + */ +@dynamicMemberLookup +public struct ExpressWrappedDictionary: Collection { + + public typealias WrappedType = [ String : V ] + + public typealias Key = WrappedType.Key + public typealias Value = WrappedType.Value + public typealias Keys = WrappedType.Keys + public typealias Values = WrappedType.Values + + public typealias Element = WrappedType.Element + public typealias Index = WrappedType.Index + public typealias Indices = WrappedType.Indices + public typealias SubSequence = WrappedType.SubSequence + public typealias Iterator = WrappedType.Iterator + + public var dictionary : WrappedType + + @inlinable + public init(_ dictionary: WrappedType) { self.dictionary = dictionary } +} + +extension ExpressWrappedDictionary: Equatable where V: Equatable {} +extension ExpressWrappedDictionary: Hashable where V: Hashable {} + +public extension ExpressWrappedDictionary { + + // MARK: - Dictionary + + @inlinable var keys : Keys { return dictionary.keys } + @inlinable var values : Values { return dictionary.values } + + @inlinable subscript(_ key: Key) -> Value? { + set { dictionary[key] = newValue } + get { return dictionary[key] } + } + @inlinable subscript(key: Key, default defaultValue: @autoclosure () -> Value) + -> Value + { + set { dictionary[key] = newValue } // TBD + get { return self[key] ?? defaultValue() } + } + + // MARK: - Sequence + + @inlinable + func makeIterator() -> Iterator { return dictionary.makeIterator() } + + // MARK: - Collection + + @inlinable var indices : Indices { return dictionary.indices } + @inlinable var startIndex : Index { return dictionary.startIndex } + @inlinable var endIndex : Index { return dictionary.endIndex } + @inlinable func index(after i: Index) -> Index { + return dictionary.index(after: i) + } + @inlinable + func formIndex(after i: inout Index) { dictionary.formIndex(after: &i) } + + @inlinable + subscript(position: Index) -> Element { return dictionary[position] } + + @inlinable + subscript(bounds: Range) -> Slice { + return dictionary[bounds] + } + @inlinable func index(forKey key: Key) -> Index? { + return dictionary.index(forKey: key) + } + + @inlinable var isEmpty : Bool { return dictionary.isEmpty } + @inlinable var first : (key: Key, value: Value)? { return dictionary.first } + + @inlinable + var underestimatedCount: Int { return dictionary.underestimatedCount } + + @inlinable var count: Int { return dictionary.count } + + // MARK: - Dynamic Member Lookup + + @inlinable + subscript(dynamicMember k: String) -> Value? { return dictionary[k] } +} + +public extension ExpressWrappedDictionary { + + @inlinable + subscript(int key: Key) -> Int? { + guard let v = self[key] else { return nil } + if let i = (v as? Int) { return i } + #if swift(>=5.10) + if let i = (v as? any BinaryInteger) { return Int(i) } + #endif + return Int("\(v)") + } + + @inlinable + subscript(string key: Key) -> String? { + guard let v = self[key] else { return nil } + if let s = (v as? String) { return s } + return String(describing: v) + } +} diff --git a/Sources/express/IncomingMessage.swift b/Sources/express/IncomingMessage.swift index a3e43c0..b6b08a8 100644 --- a/Sources/express/IncomingMessage.swift +++ b/Sources/express/IncomingMessage.swift @@ -12,8 +12,12 @@ import class http.IncomingMessage import NIOHTTP1 + public extension IncomingMessage { + typealias Params = ExpressWrappedDictionary + typealias Query = ExpressWrappedDictionary + // TODO: baseUrl, originalUrl, path // TODO: hostname, ip, ips, protocol @@ -34,15 +38,15 @@ public extension IncomingMessage { * } * ``` */ - var params : [ String : String ] { + var params : Params { set { environment[ExpressExtKey.Params.self] = newValue } get { return environment[ExpressExtKey.Params.self] } } - + /** * Returns the query parameters as parsed by the `qs.parse` function. */ - var query : [ String : Any ] { + var query : Query { if let q = environment[ExpressExtKey.Query.self] { return q } // this should be filled by Express when the request arrives. It depends on @@ -57,13 +61,13 @@ public extension IncomingMessage { // FIXME: improve parser (fragments?!) // TBD: just use Foundation?! guard let idx = url.firstIndex(of: "?") else { - environment[ExpressExtKey.Query.self] = [:] - return [:] + environment[ExpressExtKey.Query.self] = .init([:]) + return Query([:]) } let q = url[url.index(after: idx)...] let qp = qs.parse(String(q)) - environment[ExpressExtKey.Query.self] = qp - return qp + environment[ExpressExtKey.Query.self] = .init(qp) + return Query(qp) } /** diff --git a/Sources/express/Route.swift b/Sources/express/Route.swift index c17f6f4..09558b9 100644 --- a/Sources/express/Route.swift +++ b/Sources/express/Route.swift @@ -169,7 +169,7 @@ open class Route: MiddlewareObject, ErrorMiddlewareObject, RouteKeeper, } }() - let params : [ String : String ] + let params : IncomingMessage.Params let matchPath : String? if let pattern = urlPattern { // this route has a path pattern assigned var newParams = req.params // TBD diff --git a/Sources/express/RoutePattern.swift b/Sources/express/RoutePattern.swift index 567e9be..b78e318 100644 --- a/Sources/express/RoutePattern.swift +++ b/Sources/express/RoutePattern.swift @@ -112,7 +112,7 @@ public enum RoutePattern: Hashable { */ static func match(pattern p: [ RoutePattern ], against escapedPathComponents: [ String ], - variables: inout [ String : String ]) -> String? + variables: inout IncomingMessage.Params) -> String? { // Note: Express does a prefix match, which is important for mounting. // TODO: Would be good to support a "$" pattern which guarantees an exact diff --git a/Sources/express/ServerResponse.swift b/Sources/express/ServerResponse.swift index 978da0a..156b1a9 100644 --- a/Sources/express/ServerResponse.swift +++ b/Sources/express/ServerResponse.swift @@ -24,6 +24,8 @@ public extension ServerResponse { get { return environment[ExpressExtKey.RequestKey.self] } } + typealias Locals = ExpressWrappedDictionary + /** * This is legacy, an app can also just use `EnvironmentKey`s with either * `IncomingMessage` or `ServerResponse`. @@ -31,7 +33,7 @@ public extension ServerResponse { * * Traditionally `locals` was used to store Stringly-typed keys & values. */ - var locals : [ String : Any ] { + var locals : Locals { set { environment[ExpressExtKey.Locals.self] = newValue } get { return environment[ExpressExtKey.Locals.self] } } From 8104a7228011e324cacfc5481e2faf748319784e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:34:02 +0200 Subject: [PATCH 10/18] GHA: Drop Bionic/Xenial Those seem to be b0rked upstream? --- .github/workflows/swift.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 18d76fd..a4d6e73 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -13,8 +13,8 @@ jobs: fail-fast: false matrix: image: - - swift:5.5.3-xenial - - swift:5.6.1-bionic + - swift:5.5.3-focal + - swift:5.9.2-focal - swift:5.10.1-noble container: ${{ matrix.image }} steps: From 7cc0105f53e876b6d4c24a7bf7ca2168437e0111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:44:20 +0200 Subject: [PATCH 11/18] Disable color logging in dumb TERM environment ... like Xcode. --- Sources/connect/Logger.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/connect/Logger.swift b/Sources/connect/Logger.swift index 8846215..a9553cc 100644 --- a/Sources/connect/Logger.swift +++ b/Sources/connect/Logger.swift @@ -3,7 +3,7 @@ // Noze.io / Macro // // Created by Helge Heß on 31/05/16. -// Copyright © 2016-2020 ZeeZide GmbH. All rights reserved. +// Copyright © 2016-2024 ZeeZide GmbH. All rights reserved. // import enum MacroCore.process @@ -169,10 +169,19 @@ private struct LogInfoProvider { } } +#if os(Windows) + import func WinSDK.strcmp +#elseif os(Linux) + import func Glibc.strcmp +#else + import func Darwin.strcmp +#endif + fileprivate let shouldDoColorLogging : Bool = { // TODO: Add `isTTY` from Noze let isStdoutTTY = isatty(xsys.STDOUT_FILENO) != 0 if !isStdoutTTY { return false } + if let s = xsys.getenv("TERM"), strcmp(s, "dumb") == 0 { return false } if process.isRunningInXCode { return false } return true }() From 22ce0ec6f2cb27fc072b4ccee34125cfcfac3f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:44:58 +0200 Subject: [PATCH 12/18] Swift 5.5 compat ... --- Sources/connect/BodyParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/connect/BodyParser.swift b/Sources/connect/BodyParser.swift index 97bec38..646ecd2 100644 --- a/Sources/connect/BodyParser.swift +++ b/Sources/connect/BodyParser.swift @@ -364,7 +364,7 @@ public enum bodyParser { { self.limit = limit self.extended = extended - if let type { self.type = { typeIs($0, [ type ]) != nil } } + if let type = type { self.type = { typeIs($0, [ type ]) != nil } } } } From 3cbcc968bb7ec3991b2b511c383ee22a4092d533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 14 Jul 2024 18:47:47 +0200 Subject: [PATCH 13/18] Another Swift 5.5 fix ... --- Sources/connect/BodyParser.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/connect/BodyParser.swift b/Sources/connect/BodyParser.swift index 646ecd2..7adddd0 100644 --- a/Sources/connect/BodyParser.swift +++ b/Sources/connect/BodyParser.swift @@ -377,8 +377,10 @@ public enum bodyParser { fileprivate extension bodyParser.Options { func checkType(_ req: IncomingMessage, defaultType: String? = nil) -> Bool { - if let type { return type(req) } - if let defaultType { return typeIs(req, [ defaultType ]) != nil } + if let type = type { return type(req) } + if let defaultType = defaultType { + return typeIs(req, [ defaultType ]) != nil + } return true } } From fa0411d5790f5ca943e6a599c716882daae63489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sun, 8 Sep 2024 15:19:08 +0200 Subject: [PATCH 14/18] GHA: Use Xcode latest and checkout v4 ... --- .github/workflows/swift.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index a4d6e73..8a7ab40 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -34,7 +34,7 @@ jobs: with: xcode-version: latest - name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build Swift Debug Package run: swift build -c debug - name: Build Swift Release Package From 45d5c1efc92cf11551c5c05d8968e0be5d810f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Thu, 19 Sep 2024 13:11:05 +0200 Subject: [PATCH 15/18] GHA: Build against Swift 6 ... and use checkout v4. --- .github/workflows/swift.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 8a7ab40..f00196f 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -13,13 +13,12 @@ jobs: fail-fast: false matrix: image: - - swift:5.5.3-focal - swift:5.9.2-focal - - swift:5.10.1-noble + - swift:6.0-noble container: ${{ matrix.image }} steps: - name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build Swift Debug Package run: swift build -c debug - name: Build Swift Release Package From 09940b509e901c8ebd99660e4ee6ee72ae6e6103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Mon, 7 Oct 2024 23:57:30 +0200 Subject: [PATCH 16/18] Add `description` to Cookies object ... we want to debug stuff. --- Sources/connect/Cookies.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Sources/connect/Cookies.swift b/Sources/connect/Cookies.swift index c2187a8..5fc7ce1 100644 --- a/Sources/connect/Cookies.swift +++ b/Sources/connect/Cookies.swift @@ -94,6 +94,34 @@ public final class Cookies { } } +extension Cookies: CustomStringConvertible { + public var description: String { + var ms = " Date: Mon, 7 Oct 2024 23:58:41 +0200 Subject: [PATCH 17/18] Bugfix: Fix Cookie decoding This was using String(description:) which used to return the String of a UTF-8 sequence, but doesn't do so anymore. Use proper UTF8 decoding! --- Sources/connect/Cookies.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/connect/Cookies.swift b/Sources/connect/Cookies.swift index 5fc7ce1..f9a3d41 100644 --- a/Sources/connect/Cookies.swift +++ b/Sources/connect/Cookies.swift @@ -257,9 +257,9 @@ extension String { let splits = utf8.split(separator: c, maxSplits: 1) guard splits.count > 1 else { return ( self, "" ) } assert(splits.count == 2, "max split was 1, but got more items?") - // TODO: using describing here is wrong - let s0 = splits[0], s1 = splits[1] - return ( String(describing: s0), String(describing: s1) ) + let s0 : UTF8View.SubSequence = splits[0], s1 = splits[1] + return ( String(decoding: s0, as: UTF8.self), + String(decoding: s1, as: UTF8.self) ) } } From 88f72ccb5c8c63af47bcb8b01538b1db80de1b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Tue, 8 Oct 2024 00:05:12 +0200 Subject: [PATCH 18/18] Use latest Macro ... --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 7b42a44..cd38657 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/Macro-swift/Macro.git", - from: "1.0.0"), + from: "1.0.2"), .package(url: "https://github.com/AlwaysRightInstitute/mustache.git", from: "1.0.1") ],