Skip to content

Instantly share code, notes, and snippets.

@paulofaria
Last active April 26, 2017 17:30
Show Gist options
  • Save paulofaria/f59b17d6037893998f9d713554494cf5 to your computer and use it in GitHub Desktop.
Save paulofaria/f59b17d6037893998f9d713554494cf5 to your computer and use it in GitHub Desktop.
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