Sources/Kitura/RouterResponse.swift
/*
* Copyright IBM Corporation 2016
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import KituraNet
import KituraTemplateEngine
import LoggerAPI
import Foundation
import KituraContracts
#if swift(>=4.1)
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#endif
// MARK: RouterResponse
/**
The RouterResponse class is used to define and work with the response that will be sent by the `Router`.
It contains and allows access to the HTTP response code (e.g. 404 "Not Found"), the HTTP `Headers`
and the body of the response.
It can also render template files, using a template engine registered to the router.
### Usage Example: ###
In this example "response" is an instance of the class `RouterResponse`.
The content type and status code of the response are set.
The String "Hello world" is added to the body and the response is transmitted.
```swift
router.get("/example") { _, response, next in
response.headers["Content-Type"] = "text/html"
response.status(.OK)
try response.send("Hello world").end()
}
```
*/
public class RouterResponse {
// MARK: Properties
struct State {
weak var response: RouterResponse?
/// Whether the response has ended
var invokedEnd = false
/// Whether data has been added to buffer
var invokedSend = false {
didSet {
if invokedSend && response?.statusCode == .unknown {
// change statusCode to .OK
response?.statusCode = .OK
}
}
}
}
/// A set of functions called during the life cycle of a Request.
/// As The life cycle functions/closures may capture various things including the
/// response object in question, each life cycle function needs a reset function
/// to clear out any reference cycles that may have occurred.
struct Lifecycle {
/// Lifecycle hook called on end()
var onEndInvoked: LifecycleHandler = {}
/// Current pre-write lifecycle handler
var writtenDataFilter: WrittenDataFilter = { body in
return body
}
mutating func resetOnEndInvoked() {
onEndInvoked = {}
}
mutating func resetWrittenDataFilter() {
writtenDataFilter = { body in
return body
}
}
}
/// The server response
let response: ServerResponse
/// The router stack
private var routerStack: Stack<Router>
/// The associated request
let request: RouterRequest
/// The buffer used for output
private let buffer = BufferList()
/// State of the request
var state = State()
private var lifecycle = Lifecycle()
private let encoders: [MediaType: () -> BodyEncoder]
private let defaultResponseMediaType: MediaType
// regex used to sanitize javascript identifiers
fileprivate static let sanitizeJSIdentifierRegex: NSRegularExpression! = {
do {
return try NSRegularExpression(pattern: "[^\\[\\]\\w$.]", options: [])
} catch { // pattern is a known valid literal, should never throw
Log.error("Error initializing sanitizeJSIdentifierRegex: \(error)")
return nil
}
}()
/// Set of cookies to return with the response.
public var cookies = [String: HTTPCookie]()
/// Optional error value.
public var error: Swift.Error?
/// HTTP headers of the response.
public var headers: Headers
/// HTTP status code of the response.
public var statusCode: HTTPStatusCode {
get {
return response.statusCode ?? .unknown
}
set(newValue) {
response.statusCode = newValue
}
}
/// User info.
/// Can be used by middlewares and handlers to store and pass information on to subsequent handlers.
public var userInfo: [String: Any] = [:]
/// Initialize a `RouterResponse` instance
///
/// - Parameter response: The `ServerResponse` object to work with
/// - Parameter routerStack: The stack of `Router` instances that this `RouterResponse` is
/// working with.
/// - Parameter request: The `RouterRequest` object that is paired with this
/// `RouterResponse` object.
/// - Parameter encoder: The `BodyEncoder` that will be used the encode the request body.
/// - Parameter mediaType: The `MediaType` the media type which will be sent in the response "Content-Type" header.
init(response: ServerResponse, routerStack: Stack<Router>, request: RouterRequest, encoders: [MediaType: () -> BodyEncoder], defaultResponseMediaType: MediaType) {
self.response = response
self.routerStack = routerStack
self.request = request
self.encoders = encoders
self.defaultResponseMediaType = defaultResponseMediaType
headers = Headers(headers: response.headers)
statusCode = .unknown
state.response = self
}
deinit {
if !state.invokedEnd {
if !state.invokedSend && statusCode == .unknown {
statusCode = .serviceUnavailable
}
do {
try end()
} catch {
Log.warning("Error in RouterResponse end(): \(error)")
}
}
}
/// Add a cookie to the response.
///
/// This function creates an `HTTPCookie` from the provided attributes and adds it to the `cookies` dictionary.
/// - Parameter name: The cookie’s name.
/// - Parameter value: The cookie‘s value.
/// - Parameter domain: The domain of the cookie.
/// - Parameter path: The cookie’s path.
/// - Parameter otherAttributes: An array of any other optional cookie attributes
public func addCookie(name: String, value: String, domain: String, path: String, otherAttributes: [AdditionalCookieAttribute]? = nil ) {
var cookieProperties = [HTTPCookiePropertyKey: Any]()
cookieProperties[HTTPCookiePropertyKey.name] = name
cookieProperties[HTTPCookiePropertyKey.value] = value
cookieProperties[HTTPCookiePropertyKey.domain] = domain
cookieProperties[HTTPCookiePropertyKey.path] = path
if let otherAttributes = otherAttributes, otherAttributes.isEmpty == false {
for attribute in otherAttributes {
switch attribute._value {
case .portList(let ports):
if let ports = ports {
cookieProperties[HTTPCookiePropertyKey.port] = ports
}
case .expires(let expiresDate):
if let expiresDate = expiresDate {
cookieProperties[HTTPCookiePropertyKey.expires] = expiresDate
}
case .maximumAge(let maxAge):
cookieProperties[HTTPCookiePropertyKey.maximumAge] = maxAge
case .originURL(let originURL):
cookieProperties[HTTPCookiePropertyKey.originURL] = originURL
case .version(let cookieVersion):
cookieProperties[HTTPCookiePropertyKey.version] = cookieVersion
case .discard(let discard):
cookieProperties[HTTPCookiePropertyKey.discard] = discard
case .isSecure(let secure):
cookieProperties[HTTPCookiePropertyKey.secure] = secure ? "YES" : "NO"
case .comment(let comment):
if let comment = comment {
cookieProperties[HTTPCookiePropertyKey.comment] = comment
}
case .commentURL(let commentURL):
if let commentURL = commentURL {
cookieProperties[HTTPCookiePropertyKey.commentURL] = commentURL
}
}
}
if let cookie = HTTPCookie(properties: cookieProperties) {
cookies[cookie.name] = cookie
}
}
}
// MARK: End
/// End the response.
///
/// - Throws: Socket.Error if an error occurred while writing to a socket.
public func end() throws {
guard !state.invokedEnd else {
Log.warning("RouterResponse end() invoked more than once for \(self.request.urlURL)")
return
}
lifecycle.onEndInvoked()
lifecycle.resetOnEndInvoked()
// Sets status code if unset
if statusCode == .unknown {
statusCode = .OK
}
let content = lifecycle.writtenDataFilter(buffer.data)
lifecycle.resetWrittenDataFilter()
let contentLength = headers["Content-Length"]
if contentLength == nil {
headers["Content-Length"] = String(content.count)
}
if cookies.count > 0 {
addCookies()
}
if request.method != .head {
try response.write(from: content)
}
state.invokedEnd = true
try response.end()
}
/// Add Set-Cookie headers
private func addCookies() {
var cookieStrings = [String]()
for (_, cookie) in cookies {
var cookieString = cookie.name + "=" + cookie.value + "; path=" + cookie.path + "; domain=" + cookie.domain
if let expiresDate = cookie.expiresDate {
cookieString += "; expires=" + SPIUtils.httpDate(expiresDate)
}
if cookie.isSecure {
cookieString += "; secure; HTTPOnly"
}
cookieStrings.append(cookieString)
}
response.headers.append("Set-Cookie", value: cookieStrings)
}
/**
* Return the highest rated encoder for the request's Accepts header.
* Defaults to the `defaultResponseMediaType` if there no successful match.
* If there is no encoder for the `defaultResponseMediaType`, a JSONEncoder() is used.
*
* ### Usage Example: ###
* ```swift
* let (mediaType, encoder) = selectResponseEncoder(request)
* ```
* - Parameter request: The RouterRequest to check
* - Returns: A tuple of the highest rated MediaType and it's corresponding Encoder, or a JSONEncoder() if no encoders match the Accepts header and it's corresponding .
*/
private func selectResponseEncoder(_ request: RouterRequest) -> (MediaType, BodyEncoder) {
let acceptHeader = request.headers["accept"]
if encoders.count == 1 ||
acceptHeader == nil ||
acceptHeader == "*" ||
acceptHeader == "*/*" {
if let defaultEncoder = encoders[defaultResponseMediaType] {
return (defaultResponseMediaType, defaultEncoder())
} else {
return (.json, JSONEncoder())
}
}
let encoderAcceptsTypes = Array(encoders.keys).map { $0.description }
guard let bestAccepts = request.accepts(types: encoderAcceptsTypes),
let bestMediaType = MediaType(bestAccepts),
let bestEncoder = encoders[bestMediaType]
else {
if let defaultEncoder = encoders[defaultResponseMediaType] {
return (defaultResponseMediaType, defaultEncoder())
} else {
return (.json, JSONEncoder())
}
}
return (bestMediaType, bestEncoder())
}
// MARK: Status Code
/// Set the status code.
///
/// - Parameter status: The HTTP status code object.
/// - Returns: This RouterResponse.
@discardableResult
public func status(_ status: HTTPStatusCode) -> RouterResponse {
response.statusCode = status
return self
}
// MARK: Redirect
/// Redirect to path with status code.
///
/// - Parameter: The path for the redirect.
/// - Parameter: The status code for the redirect.
/// - Throws: Socket.Error if an error occurred while writing to a socket.
/// - Returns: This RouterResponse.
@discardableResult
public func redirect(_ path: String, status: HTTPStatusCode = .movedTemporarily) throws -> RouterResponse {
headers.setLocation(path)
try self.status(status).end()
return self
}
// MARK: Render
// influenced by http://expressjs.com/en/4x/api.html#app.render
/// Render a resource using Router's template engine.
///
/// - Parameter resource: The resource name without extension.
/// - Parameter context: A dictionary of local variables of the resource.
/// - Parameter options: Rendering options, specific per template engine
/// - Throws: TemplatingError if no file extension was specified or there is no template engine defined for the extension.
/// - Returns: This RouterResponse.
@discardableResult
public func render(_ resource: String, context: [String: Any],
options: RenderingOptions = NullRenderingOptions()) throws -> RouterResponse {
guard let router = getRouterThatCanRender(resource: resource) else {
throw TemplatingError.noTemplateEngineForExtension(extension: "")
}
let renderedResource = try router.render(template: resource, context: context, options: options)
return send(renderedResource)
}
/// Render a resource using Router's template engine.
///
/// - Parameter resource: The resource name without extension.
/// - Parameter with: A value that conforms to Encodable that is used to generate the content.
/// - Parameter forKey: A value used to match the Encodable value to the correct variable in a template file.
/// The `forKey` value should match the desired variable in the template file.
/// - Parameter options: Rendering options, specific per template engine
/// - Throws: TemplatingError if no file extension was specified or there is no template engine defined for the extension.
/// - Returns: This RouterResponse.
@discardableResult
public func render<T: Encodable>(_ resource: String, with value: T, forKey key: String? = nil,
options: RenderingOptions = NullRenderingOptions()) throws -> RouterResponse {
guard let router = getRouterThatCanRender(resource: resource) else {
throw TemplatingError.noTemplateEngineForExtension(extension: "")
}
let renderedResource = try router.render(template: resource, with: value, forKey: key, options: options)
return send(renderedResource)
}
/// Render a resource using Router's template engine.
///
/// - Parameter resource: The resource name without extension.
/// - Parameter with: An array of tuples of type (Identifier, Encodable). The Encodable values are used to generate the content.
/// - Parameter forKey: A value used to match the Encodable values to the correct variable in a template file.
/// The `forKey` value should match the desired variable in the template file.
/// - Parameter options: Rendering options, specific per template engine
/// - Throws: TemplatingError if no file extension was specified or there is no template engine defined for the extension.
/// - Returns: This RouterResponse.
@discardableResult
public func render<I: Identifier, T: Encodable>(_ resource: String, with values: [(I, T)], forKey key: String,
options: RenderingOptions = NullRenderingOptions()) throws -> RouterResponse {
guard let router = getRouterThatCanRender(resource: resource) else {
throw TemplatingError.noTemplateEngineForExtension(extension: "")
}
let items: [T] = values.map { $0.1 }
let renderedResource = try router.render(template: resource, with: items, forKey: key, options: options)
return send(renderedResource)
}
private func getRouterThatCanRender(resource: String) -> Router? {
var routerStackToTraverse = routerStack
while routerStackToTraverse.topItem != nil {
let router = routerStackToTraverse.pop()
if router.getTemplateEngine(template: resource) != nil {
return router
}
}
return nil
}
/// Push router into router stack
///
/// - Parameter: router - router to push
func push(router: Router) {
routerStack.push(router)
}
/// Pop router from router stack
func popRouter() {
let _ = routerStack.pop()
}
// MARK: Set Properties
/// Set the pre-flush lifecycle handler and return the previous one.
///
/// - Parameter newOnEndInvoked: The new pre-flush lifecycle handler.
/// - Returns: The old pre-flush lifecycle handler.
public func setOnEndInvoked(_ newOnEndInvoked: @escaping LifecycleHandler) -> LifecycleHandler {
let oldOnEndInvoked = lifecycle.onEndInvoked
lifecycle.onEndInvoked = newOnEndInvoked
return oldOnEndInvoked
}
/// Set the written data filter and return the previous one.
///
/// - Parameter newWrittenDataFilter: The new written data filter.
/// - Returns: The old written data filter.
public func setWrittenDataFilter(_ newWrittenDataFilter: @escaping WrittenDataFilter) -> WrittenDataFilter {
let oldWrittenDataFilter = lifecycle.writtenDataFilter
lifecycle.writtenDataFilter = newWrittenDataFilter
return oldWrittenDataFilter
}
// MARK: Content Negotiation
/// Perform content-negotiation on the Accept HTTP header on the request, when present.
///
/// Uses request.accepts() to select a handler for the request, based on the acceptable types ordered by their
/// quality values. If the header is not specified, the default callback is invoked. When no match is found,
/// the server invokes the default callback if exists, or responds with 406 “Not Acceptable”.
/// The Content-Type response header is set when a callback is selected.
///
/// - Parameter callbacks: A dictionary that maps content types to handlers.
/// - Throws: Socket.Error if an error occurred while writing to a socket.
public func format(callbacks: [String: ((RouterRequest, RouterResponse) -> Void)]) throws {
let callbackTypes = Array(callbacks.keys)
if let acceptType = request.accepts(types: callbackTypes) {
headers["Content-Type"] = acceptType
callbacks[acceptType]!(request, self)
} else if let defaultCallback = callbacks["default"] {
defaultCallback(request, self)
} else {
try status(.notAcceptable).end()
}
}
// MARK: Send String
/// Send a UTF-8 encoded string.
///
/// - Parameter str: The string to send.
/// - Returns: This RouterResponse.
@discardableResult
public func send(_ str: String) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(str:) invoked after end() for \(self.request.urlURL)")
return self
}
let count = str.utf8.count
str.withCString {
$0.withMemoryRebound(to: UInt8.self, capacity: count) {
buffer.append(bytes: $0, length: count)
state.invokedSend = true
}
}
return self
}
/// Send an optional string.
/// If the `String?` can be unwrapped it is sent as a String, otherwise the empty string ("") is sent.
///
/// - Parameter str: The string to send.
/// - Returns: This RouterResponse.
@discardableResult
public func send(_ str: String?) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(str:) invoked after end() for \(self.request.urlURL)")
return self
}
guard let str = str else {
Log.warning("RouterResponse send(str:) invoked with a nil value")
return send("")
}
return send(str)
}
/// Set the HTTP status code of the RouterResponse and send the String description of the HTTP status code.
///
/// - Parameter status: The HTTP status code.
/// - Returns: This RouterResponse.
public func send(status: HTTPStatusCode) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(status:) invoked after end() for \(self.request.urlURL)")
return self
}
self.status(status)
send(HTTPURLResponse.localizedString(forStatusCode: status.rawValue))
return self
}
// MARK: Send Data
/// Send data.
///
/// - Parameter data: The data to send.
/// - Returns: This RouterResponse.
@discardableResult
public func send(data: Data) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(data:) invoked after end() for \(self.request.urlURL)")
return self
}
buffer.append(data: data)
state.invokedSend = true
return self
}
/// Send a file.
///
/// - Parameter fileName: The name of the file to send.
/// - Throws: An error in the Cocoa domain, if the file cannot be read.
/// - Returns: This RouterResponse.
///
/// - Note: Sets the Content-Type header based on the "extension" of the file.
/// If the fileName is relative, it is relative to the current directory.
@discardableResult
public func send(fileName: String) throws -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(fileName:) invoked after end() for \(self.request.urlURL)")
return self
}
let data = try Data(contentsOf: URL(fileURLWithPath: fileName))
let contentType = ContentType.sharedInstance.getContentType(forFileName: fileName)
if let contentType = contentType {
headers["Content-Type"] = contentType
}
send(data: data)
return self
}
/// Set headers and attach file for downloading.
///
/// - Parameter download: The file to download.
/// - Throws: An error in the Cocoa domain, if the file cannot be read.
public func send(download: String) throws {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(download:) invoked after end() for \(self.request.urlURL)")
return
}
try send(fileName: StaticFileServer.ResourcePathHandler.getAbsolutePath(for: download))
headers.addAttachment(for: download)
}
typealias JSONSerializationType = JSONSerialization
/// Sets the Content-Type header as application/json, Serializes an array into JSON data and sends the data.
/// If the data is not a valid JSON structure, it will not be sent and a warning will be logged.
///
/// - Parameter json: The array to send in JSON format.
/// - Returns: This RouterResponse.
@discardableResult
public func send(json: [Any]) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(json:) invoked after end() for \(self.request.urlURL)")
return self
}
do {
let jsonData = try JSONSerializationType.data(withJSONObject: json, options:.prettyPrinted)
headers.setType("json")
send(data: jsonData)
} catch {
Log.warning("Failed to convert JSON for sending: \(error.localizedDescription)")
}
return self
}
/// Sets the Content-Type header as "application/json",
/// Serializes a dictionary into JSON data and sends the data.
/// If the data is not a valid JSON structure, it will not be sent and a warning will be logged.
///
/// - Parameter json: The Dictionary to send in JSON format as a hash.
/// - Returns: This RouterResponse.
@discardableResult
public func send(json: [String: Any]) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(json:) invoked after end() for \(self.request.urlURL)")
return self
}
do {
let jsonData = try JSONSerializationType.data(withJSONObject: json, options:.prettyPrinted)
headers.setType("json")
send(data: jsonData)
} catch {
Log.warning("Failed to convert JSON for sending: \(error.localizedDescription)")
}
return self
}
}
extension RouterResponse {
// MARK: Send Encodable
/// Sends an Encodable type, encoded using the preferred `BodyEncoder` based on the "Accept" header sent in the request, and sets the Content-Type header appropriately.
/// If no Accept header was provided, or if no suitable encoder is registered with the router, the encoder corresponding to the `defaultResponseMediaType` will be used.
///
/// - Parameter obj: The Codable object to send.
/// - Returns: This RouterResponse.
@discardableResult
public func send<T : Encodable>(_ obj: T) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(_ obj:) invoked after end() for \(self.request.urlURL)")
return self
}
do {
let (mediaType, encoder) = selectResponseEncoder(request)
headers["Content-Type"] = mediaType.description
send(data: try encoder.encode(obj))
} catch {
Log.warning("Failed to encode Codable object for sending: \(error.localizedDescription)")
status(.internalServerError)
}
return self
}
/// Sets the Content-Type header as "application/json",
/// Encodes an Encodable object into data using a `JSONEncoder()` and sends the data.
///
/// - Parameter json: The JSON Encodable object to send.
/// - Returns: This RouterResponse.
@discardableResult
public func send<T : Encodable>(json: T) -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(_ obj:) invoked after end() for \(self.request.urlURL)")
return self
}
do {
headers.setType("json")
send(data: try (encoders[.json]?() ?? JSONEncoder()).encode(json))
} catch {
Log.warning("Failed to encode Codable object for sending: \(error.localizedDescription)")
status(.internalServerError)
}
return self
}
/// Encodes an Encodable object into data using a `JSONEncoder()`
/// and sends the data with JSONP callback.
///
/// - Parameter json: The JSON object to send.
/// - Parameter callbackParameter: The name of the URL query
/// parameter whose value contains the JSONP callback function.
///
/// - Throws: `JSONPError.invalidCallbackName` if the the callback
/// query parameter of the request URL is missing or its value is
/// empty or contains invalid characters (the set of valid characters
/// is the alphanumeric characters and `[]$._`).
/// - Returns: This RouterResponse.
public func send<T : Encodable>(jsonp: T, callbackParameter: String = "callback") throws -> RouterResponse {
guard !state.invokedEnd else {
Log.warning("RouterResponse send(jsonp:) invoked after end() for \(self.request.urlURL)")
return self
}
func sanitizeJSIdentifier(_ ident: String) -> String {
return RouterResponse.sanitizeJSIdentifierRegex.stringByReplacingMatches(in: ident, options: [],
range: NSRange(location: 0, length: ident.utf16.count), withTemplate: "")
}
func validJsonpCallbackName(_ name: String?) -> String? {
if let name = name {
if name.count > 0 && name == sanitizeJSIdentifier(name) {
return name
}
}
return nil
}
func jsonToJS(_ json: String) -> String {
// Translate JSON characters that are invalid in javascript
return json.replacingOccurrences(of: "\u{2028}", with: "\\u2028")
.replacingOccurrences(of: "\u{2029}", with: "\\u2029")
}
let jsonStr = String(data: try (encoders[.json]?() ?? JSONEncoder()).encode(jsonp), encoding: .utf8)!
let taintedJSCallbackName = request.queryParameters[callbackParameter]
if let jsCallbackName = validJsonpCallbackName(taintedJSCallbackName) {
headers.setType("js")
// Set header "X-Content-Type-Options: nosniff" and prefix body with
// "/**/ " as security mitigation for Flash vulnerability
// CVE-2014-4671, CVE-2014-5333 "Abusing JSONP with Rosetta Flash"
headers["X-Content-Type-Options"] = "nosniff"
send("/**/ " + jsCallbackName + "(" + jsonToJS(jsonStr) + ")")
} else {
throw JSONPError.invalidCallbackName(name: taintedJSCallbackName)
}
return self
}
}
/// Type alias for "Before flush" (i.e. before headers and body are written) lifecycle handler.
public typealias LifecycleHandler = () -> Void
/// Type alias for written data filter, i.e. pre-write lifecycle handler.
public typealias WrittenDataFilter = (Data) -> Data