Last active
April 26, 2017 17:30
-
-
Save paulofaria/f59b17d6037893998f9d713554494cf5 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
struct HTTPRequest { | |
enum Method { | |
case get | |
case post | |
case put | |
case patch | |
case delete | |
case head | |
case options | |
} | |
let method: Method | |
let path: String | |
let headers: [String: String] | |
init(method: Method, path: String, headers: [String: String] = [:]) { | |
self.method = method | |
self.path = path | |
self.headers = headers | |
} | |
} | |
extension HTTPRequest.Method : CustomStringConvertible { | |
var description: String { | |
switch self { | |
case .get: | |
return "GET" | |
case .post: | |
return "POST" | |
case .put: | |
return "PUT" | |
case .patch: | |
return "PATCH" | |
case .delete: | |
return "DELETE" | |
case .head: | |
return "HEAD" | |
case .options: | |
return "OPTIONS" | |
} | |
} | |
} | |
extension HTTPRequest : CustomStringConvertible { | |
var description: String { | |
var description = "\(method) \(path)" | |
for (key, header) in headers { | |
description += "\n\(key): \(header)" | |
} | |
return description | |
} | |
} | |
struct HTTPResponse { | |
enum Status { | |
case ok | |
case notFound | |
case badRequest | |
case internalServerError | |
case methodNotAllowed | |
case unauthorized | |
} | |
let status: Status | |
let body: String | |
} | |
extension HTTPResponse.Status : CustomStringConvertible { | |
var description: String { | |
switch self { | |
case .ok: | |
return "OK" | |
case .notFound: | |
return "Not Found" | |
case .badRequest: | |
return "Bad Request" | |
case .internalServerError: | |
return "Internal Server Error" | |
case .methodNotAllowed: | |
return "Method Not Allowed" | |
case .unauthorized: | |
return "Unauthorized" | |
} | |
} | |
} | |
extension HTTPResponse : CustomStringConvertible { | |
var description: String { | |
return "\(status) - \(body)" | |
} | |
} | |
class Request { | |
let httpRequest: HTTPRequest | |
fileprivate var pathComponents: ArraySlice<String> | |
var pathParameters = PathParameters() | |
init(httpRequest: HTTPRequest) { | |
self.httpRequest = httpRequest | |
self.pathComponents = (httpRequest.path as NSString).pathComponents.dropFirst() | |
} | |
} | |
class Response { | |
let httpResponse: HTTPResponse | |
init(status: HTTPResponse.Status, body: String) { | |
self.httpResponse = HTTPResponse(status: status, body: body) | |
} | |
} | |
// MARK: Responder | |
protocol HTTPResponder { | |
func respond(to httpRequest: HTTPRequest) -> HTTPResponse | |
} | |
// MARK: PathParameterKey | |
struct PathParameterKey { | |
let key: String | |
init(_ key: String) { | |
self.key = key | |
} | |
} | |
// MARK: PathParameters | |
final class PathParameters { | |
var pathParameters: [String: String] = [:] | |
func set(_ pathParameter: String, for pathParameterKey: String) { | |
pathParameters[pathParameterKey] = pathParameter | |
} | |
func get(_ pathParameterKey: PathParameterKey) throws -> String { | |
guard let pathParameter = pathParameters[pathParameterKey.key] else { | |
throw RouteError.pathParameterNotFound | |
} | |
return pathParameter | |
} | |
func get<PathParameter : PathParameterInitializable>(_ pathParameterKey: PathParameterKey) throws -> PathParameter { | |
guard let pathParameter = pathParameters[pathParameterKey.key] else { | |
throw RouteError.pathParameterNotFound | |
} | |
return try PathParameter(pathParameter: pathParameter) | |
} | |
} | |
// MARK: PathParameterInitializable | |
protocol PathParameterInitializable { | |
init(pathParameter: String) throws | |
} | |
extension String : PathParameterInitializable { | |
init(pathParameter: String) throws { | |
self = pathParameter | |
} | |
} | |
extension Int : PathParameterInitializable { | |
init(pathParameter: String) throws { | |
guard let int = Int(pathParameter) else { | |
throw RouteError.invalidPathParameter | |
} | |
self.init(int) | |
} | |
} | |
extension UUID : PathParameterInitializable { | |
init(pathParameter: String) throws { | |
guard let uuid = UUID(uuidString: pathParameter) else { | |
throw RouteError.invalidPathParameter | |
} | |
self.init(uuid: uuid.uuid) | |
} | |
} | |
// MARK: Router | |
protocol Router { | |
func configure(route: Route) | |
func preprocess(request: Request) throws | |
func get(request: Request) throws -> Response | |
func post(request: Request) throws -> Response | |
func put(request: Request) throws -> Response | |
func patch(request: Request) throws -> Response | |
func delete(request: Request) throws -> Response | |
func postprocess(response: Response, for request: Request) throws | |
func recover(error: Error) throws -> Response | |
} | |
extension Router { | |
func build() -> HTTPResponder { | |
let route = Route() | |
build(route: route) | |
return route | |
} | |
fileprivate func build(route: Route) { | |
configure(route: route) | |
route.preprocess(body: preprocess(request:)) | |
route.get(body: get(request:)) | |
route.post(body: post(request:)) | |
route.put(body: put(request:)) | |
route.patch(body: patch(request:)) | |
route.delete(body: delete(request:)) | |
route.postprocess(body: postprocess(response:for:)) | |
route.recover(body: recover(error:)) | |
} | |
func configure(route: Route) {} | |
func preprocess(request: Request) throws {} | |
func get(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func post(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func put(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func patch(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func delete(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func postprocess(response: Response, for request: Request) throws {} | |
func recover(error: Error) throws -> Response { | |
throw error | |
} | |
} | |
// MARK: Resource | |
protocol Resource { | |
static var pathParameterKey: PathParameterKey { get } | |
associatedtype ID : PathParameterInitializable = String | |
func configure(route: Route) | |
func configure(subroute: Route) | |
func preprocess(request: Request) throws | |
func get(request: Request) throws -> Response | |
func post(request: Request) throws -> Response | |
func put(request: Request) throws -> Response | |
func patch(request: Request) throws -> Response | |
func delete(request: Request) throws -> Response | |
func get(request: Request, id: ID) throws -> Response | |
func post(request: Request, id: ID) throws -> Response | |
func put(request: Request, id: ID) throws -> Response | |
func patch(request: Request, id: ID) throws -> Response | |
func delete(request: Request, id: ID) throws -> Response | |
func postprocess(response: Response, for request: Request) throws | |
func recover(error: Error) throws -> Response | |
} | |
extension Resource { | |
func build() -> HTTPResponder { | |
let route = Route() | |
build(route: route) | |
return route | |
} | |
fileprivate func build(route: Route) { | |
configure(route: route) | |
route.preprocess(body: preprocess(request:)) | |
route.get(body: get(request:)) | |
route.post(body: post(request:)) | |
route.put(body: put(request:)) | |
route.patch(body: patch(request:)) | |
route.delete(body: delete(request:)) | |
route.postprocess(body: postprocess(response:for:)) | |
route.recover(body: recover(error:)) | |
route.add(Self.pathParameterKey) { subroute in | |
func wrap(body: @escaping (Request, ID) throws -> Response) -> (Request) throws -> Response { | |
return { request in | |
let id: ID = try request.pathParameters.get(Self.pathParameterKey) | |
return try body(request, id) | |
} | |
} | |
configure(subroute: subroute) | |
subroute.get(body: wrap(body: get(request:id:))) | |
subroute.post(body: wrap(body: post(request:id:))) | |
subroute.put(body: wrap(body: put(request:id:))) | |
subroute.patch(body: wrap(body: patch(request:id:))) | |
subroute.delete(body: wrap(body: delete(request:id:))) | |
subroute.recover(body: recover(error:)) | |
} | |
} | |
static var pathParameterKey: PathParameterKey { | |
return PathParameterKey(String(describing: type(of: self))) | |
} | |
func configure(route: Route) {} | |
func configure(subroute: Route) {} | |
func preprocess(request: Request) throws {} | |
func get(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func post(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func put(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func patch(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func delete(request: Request) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func get(request: Request, id: ID) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func post(request: Request, id: ID) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func put(request: Request, id: ID) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func patch(request: Request, id: ID) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func delete(request: Request, id: ID) throws -> Response { | |
throw RouteError.methodNotAllowed | |
} | |
func postprocess(response: Response, for request: Request) throws {} | |
func recover(error: Error) throws -> Response { | |
throw error | |
} | |
} | |
// MARK: Route | |
enum RouteError : Error { | |
case invalidPathParameter | |
case notFound | |
case methodNotAllowed | |
case pathParameterNotFound | |
} | |
final class Route : HTTPResponder { | |
private var subroutes: [String: Route] = [:] | |
private var pathParameterSubroute: (String, Route)? | |
private var preprocess: (Request) throws -> Void = { _ in } | |
private var responders: [HTTPRequest.Method: (Request) throws -> Response] = [:] | |
private var postprocess: (Response, Request) throws -> Void = { _ in } | |
private var recover: (Error) throws -> Response = { error in throw error } | |
init() {} | |
func add(_ pathComponent: String, body: (Route) -> Void) { | |
let route = Route() | |
body(route) | |
return subroutes[pathComponent] = route | |
} | |
func add(_ pathParameterKey: PathParameterKey, body: (Route) -> Void) { | |
let route = Route() | |
body(route) | |
pathParameterSubroute = (pathParameterKey.key, route) | |
} | |
func preprocess(body: @escaping (Request) throws -> Void) { | |
preprocess = body | |
} | |
func respond(method: HTTPRequest.Method, body: @escaping (Request) throws -> Response) { | |
responders[method] = body | |
} | |
func postprocess(body: @escaping (Response, Request) throws -> Void) { | |
postprocess = body | |
} | |
func recover(body: @escaping (Error) throws -> Response) { | |
recover = body | |
} | |
// MARK: Respond | |
func respond(to httpRequest: HTTPRequest) -> HTTPResponse { | |
do { | |
return try respond(to: Request(httpRequest: httpRequest)).httpResponse | |
} catch { | |
return defaultRecover(error: error).httpResponse | |
} | |
} | |
private func respond(to request: Request) throws -> Response { | |
do { | |
try preprocess(request) | |
let response = try getResponse(for: request) | |
try postprocess(response, request) | |
return response | |
} catch { | |
return try recover(error) | |
} | |
} | |
private func getResponse(for request: Request) throws -> Response { | |
if let pathComponent = request.pathComponents.popFirst() { | |
return try getResponse(for: request, pathComponent: pathComponent) | |
} | |
if let respond = responders[request.httpRequest.method] { | |
return try respond(request) | |
} | |
throw RouteError.notFound | |
} | |
private func getResponse(for request: Request, pathComponent: String) throws -> Response { | |
if let route = subroutes[pathComponent] { | |
return try route.respond(to: request) | |
} else if let (pathParameterKey, route) = pathParameterSubroute { | |
request.pathParameters.set(pathComponent, for: pathParameterKey) | |
return try route.respond(to: request) | |
} | |
throw RouteError.notFound | |
} | |
private func defaultRecover(error: Error) -> Response { | |
switch error { | |
case let RouteError as RouteError: | |
switch RouteError { | |
case .notFound: | |
return Response(status: .notFound, body: "Not found") | |
case .invalidPathParameter: | |
return Response(status: .badRequest, body: "Invalid path parameter") | |
case .pathParameterNotFound: | |
return Response(status: .internalServerError, body: "Path parameter not found") | |
case .methodNotAllowed: | |
return Response(status: .methodNotAllowed, body: "Method not allowed") | |
} | |
default: | |
return Response(status: .internalServerError, body: "Internal server error") | |
} | |
} | |
// MARK: Convenience | |
func get(body: @escaping (Request) throws -> Response) { | |
respond(method: .get, body: body) | |
} | |
func post(body: @escaping (Request) throws -> Response) { | |
respond(method: .post, body: body) | |
} | |
func put(body: @escaping (Request) throws -> Response) { | |
respond(method: .put, body: body) | |
} | |
func patch(body: @escaping (Request) throws -> Response) { | |
respond(method: .patch, body: body) | |
} | |
func delete(body: @escaping (Request) throws -> Response) { | |
respond(method: .delete, body: body) | |
} | |
func add<R : Router>(_ pathComponent: String, router: R) { | |
add(pathComponent, body: router.build(route:)) | |
} | |
func add<R : Resource>(_ pathComponent: String, resource: R) { | |
add(pathComponent, body: resource.build(route:)) | |
} | |
} | |
// MARK: App Module | |
protocol Database {} | |
struct App { | |
let database: Database | |
} | |
// MARK: Database Module | |
struct PostgreSQL : Database {} | |
// MARK: Web Module | |
enum AuthenticationError : Error { | |
case accessDenied | |
} | |
func authenticate(_ request: Request) throws { | |
if request.httpRequest.headers["Authentication"] != "bearer token" { | |
throw AuthenticationError.accessDenied | |
} | |
} | |
func log(_ response: Response, for request: Request) { | |
print(request.httpRequest) | |
print(response.httpResponse, "\n") | |
} | |
struct RootRouter : Router { | |
let app: App | |
func configure(route root: Route) { | |
root.add("users", resource: UsersResource(app: app)) | |
root.add("profile", router: ProfileRouter(app: app)) | |
} | |
func preprocess(request: Request) throws { | |
try authenticate(request) | |
} | |
func get(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Welcome!") | |
} | |
func postprocess(response: Response, for request: Request) throws { | |
log(response, for: request) | |
} | |
func recover(error: Error) throws -> Response { | |
switch error { | |
case AuthenticationError.accessDenied: | |
return Response(status: .unauthorized, body: "Access denied") | |
default: | |
throw error | |
} | |
} | |
} | |
extension PathParameters { | |
var userID: UsersResource.ID { | |
return try! get(UsersResource.pathParameterKey) | |
} | |
} | |
struct UsersResource : Resource { | |
let app: App | |
func configure(route users: Route) { | |
users.add("active") { active in | |
active.add("today") { today in | |
today.get { request in | |
return Response(status: .ok, body: "All users active today") | |
} | |
} | |
} | |
} | |
func configure(subroute user: Route) { | |
user.add("photos", resource: UserPhotosResource(app: app)) | |
} | |
func get(request: Request) throws -> Response { | |
return Response(status: .ok, body: "List all users") | |
} | |
func post(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Create user") | |
} | |
func get(request: Request, id: Int) throws -> Response { | |
return Response(status: .ok, body: "Show user \(id)") | |
} | |
func patch(request: Request, id: Int) throws -> Response { | |
return Response(status: .ok, body: "Update user \(id)") | |
} | |
func delete(request: Request, id: Int) throws -> Response { | |
return Response(status: .ok, body: "Remove user \(id)") | |
} | |
} | |
struct UserPhotosResource : Resource { | |
let app: App | |
func get(request: Request) throws -> Response { | |
return Response(status: .ok, body: "List all photos for user \(request.pathParameters.userID)") | |
} | |
func post(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Create photo for user \(request.pathParameters.userID)") | |
} | |
func get(request: Request, id: Int) throws -> Response { | |
return Response(status: .ok, body: "Show photo \(id) for user \(request.pathParameters.userID)") | |
} | |
func patch(request: Request, id: Int) throws -> Response { | |
return Response(status: .ok, body: "Update photo \(id) for user \(request.pathParameters.userID)") | |
} | |
func delete(request: Request, id: Int) throws -> Response { | |
return Response(status: .ok, body: "Remove photo \(id) for user \(request.pathParameters.userID)") | |
} | |
} | |
struct ProfileRouter : Router { | |
let app: App | |
func put(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Insert profile") | |
} | |
func get(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Show profile") | |
} | |
func patch(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Update profile") | |
} | |
func delete(request: Request) throws -> Response { | |
return Response(status: .ok, body: "Remove profile") | |
} | |
} | |
// MARK: Main Module | |
let psql = PostgreSQL() | |
let app = App(database: psql) | |
let root = RootRouter(app: app) | |
let responder = root.build() | |
// MARK: Web Tests Module | |
import XCTest | |
class Tests : XCTestCase { | |
func testIndex() { | |
let request = HTTPRequest(method: .get, path: "/", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Welcome!") | |
} | |
func testShowUserPhoto() { | |
let request = HTTPRequest(method: .get, path: "/users/23/photos/14", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Show photo 14 for user 23") | |
} | |
func testListUsers() { | |
let request = HTTPRequest(method: .get, path: "/users", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "List all users") | |
} | |
func testCreateUser() { | |
let request = HTTPRequest(method: .post, path: "/users", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Create user") | |
} | |
func testShowUser() { | |
let request = HTTPRequest(method: .get, path: "/users/23", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Show user 23") | |
} | |
func testListUserPhotos() { | |
let request = HTTPRequest(method: .get, path: "/users/23/photos", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "List all photos for user 23") | |
} | |
func testShowProfile() { | |
let request = HTTPRequest(method: .get, path: "/profile", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Show profile") | |
} | |
func testNotFound() { | |
let request = HTTPRequest(method: .get, path: "/profile/not/found", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Not found") | |
} | |
func testInvalidParameter() { | |
let request = HTTPRequest(method: .get, path: "/users/invalid-path-parameter", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Invalid path parameter") | |
} | |
func testMethodNotAllowed() { | |
let request = HTTPRequest(method: .delete, path: "/users", headers: ["Authentication": "bearer token"]) | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Method not allowed") | |
} | |
func testAccessDenied() { | |
let request = HTTPRequest(method: .get, path: "/access-denied") | |
let response = responder.respond(to: request) | |
XCTAssertEqual(response.body, "Access denied") | |
} | |
func testPerformance() { | |
measure { | |
let request = HTTPRequest(method: .get, path: "/users/active/today", headers: ["Authentication": "bearer token"]) | |
responder.respond(to: request) | |
} | |
} | |
func testPerformanceWithPathParameter() { | |
measure { | |
let request = HTTPRequest(method: .get, path: "/users/23/photos", headers: ["Authentication": "bearer token"]) | |
responder.respond(to: request) | |
} | |
} | |
} | |
Tests.defaultTestSuite().run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment