Skip to content
This repository has been archived by the owner on Apr 20, 2024. It is now read-only.

Commit

Permalink
Merge pull request #2 from nodes-vapor/validation
Browse files Browse the repository at this point in the history
Validation
  • Loading branch information
BrettRToomey authored Jan 12, 2017
2 parents 5ba7b41 + 930999f commit 1f9b85f
Show file tree
Hide file tree
Showing 8 changed files with 270 additions and 95 deletions.
7 changes: 7 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
os:
- linux
language: generic
sudo: required
dist: trusty
script:
- eval "$(curl -sL https://swift.vapor.sh/ci)"
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# sanitized
# Sanitized
[![Language](https://img.shields.io/badge/Swift-3-brightgreen.svg)](http://swift.org)
[![Build Status](https://travis-ci.org/nodes-vapor/sanitized.svg?branch=master)](https://travis-ci.org/nodes-vapor/sanitized)
[![codecov](https://codecov.io/gh/nodes-vapor/sanitized/branch/master/graph/badge.svg)](https://codecov.io/gh/nodes-vapor/sanitized)
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/sanitized/master/LICENSE)

Safely extract Vapor models from requests.
Safely extract and validate Vapor models from requests.

## Integration
Update your `Package.swift` file.
Expand Down Expand Up @@ -56,6 +56,49 @@ drop.post("users") { req in
}
```

## Validation 👌
This package doesn't specifically provide any validation tools, but it is capable of running your validation suite for you. Thusly, simplifying the logic in your controllers. Sanitized has two ways of accomplishing this: pre and post validation.

### Pre-init validation
This type of validation is ran before the model is initialized and is checked against the request's JSON. This type of field is useful for when you only want to check if a field exists before continuing.

Create a `preValidation` check by overriding the default implementation in your `Sanitizable` model.
```swift
static func preValidate(data: JSON) throws {
// we only want to ensure that `name` exists/
guard data["name"]?.string != nil else {
throw MyError.invalidRequest("Name not provided.")
}
}
```

### Post-init validation
This type of validation is ran after the model has been initialized is useful for checking the content of fields while using Swift-native types.

Create a `postValidation` check by overriding the default implementation in your `Sanitizable` model.
```swift
func postValidate() throws {
guard email.count > 8 else {
throw Abort.custom(
status: .badRequest,
message: "Email must be longer than 8 characters."
)
}
}
```

## Overriding error thrown on failed `init` 🔨
The error thrown by a failed `Node.extract` will be turned into a `505 Internal Server Error` if not caught and changed before being caught by Vapor's AbortMiddleware. By default, this package will catch that error and convert it into a `400 Bad Request`. If you wish to disable this for development environments or throw your own error, you can override the following default implementation:
```swift
static func updateThrownError(_ error: Error) -> AbortError {
// recreates the default behavior of `AbortMiddleware`.
return Abort.custom(
status: .internalServerError,
message: "\(error)"
)
}
```

## 🏆 Credits
This package is developed and maintained by the Vapor team at [Nodes](https://www.nodes.dk).

Expand Down
21 changes: 20 additions & 1 deletion Sources/Sanitizable+Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,32 @@ import HTTP
import Vapor

extension Request {
/// Extracts a `Model` from the Request's JSON, first stripping sensitive fields.
///
/// - Throws:
/// - badRequest: Thrown when the request doesn't have a JSON body.
/// - updateErrorThrown: `Sanitizable` models have the ability to override
/// the error thrown for when a model fails to instantiate.
///
/// - Returns: The extracted, sanitized `Model`.
public func extractModel<M: Model>() throws -> M where M: Sanitizable {
guard let json = self.json else {
throw Abort.badRequest
}

let sanitized = json.permit(M.permitted)

return try M(node: sanitized)
try M.preValidate(data: sanitized)

let model: M
do {
model = try M(node: sanitized)
} catch {
let error = M.updateThrownError(error)
throw error
}

try model.postValidate()
return model
}
}
28 changes: 28 additions & 0 deletions Sources/Sanitizable.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
import Vapor

/// A request-extractable `Model`.
public protocol Sanitizable {
/// Fields that are permitted to be deserialized from a Request's JSON.
static var permitted: [String] { get }

/// Override the error thrown when a `Model` fails to initialize.
static func updateThrownError(_ error: Error) -> AbortError

/// Validate the Request's JSON before constructing a Model.
/// Useful for checking if fields exist.
static func preValidate(data: JSON) throws

/// Validate all deserialized fields.
func postValidate() throws
}

extension Sanitizable {
public static func updateThrownError(_ error: Error) -> AbortError {
return Abort.badRequest
}

public static func preValidate(data: JSON) throws {

}

public func postValidate() throws {

}
}
5 changes: 3 additions & 2 deletions Tests/LinuxMain.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import XCTest
@testable import sanitizedTests

@testable import SanitizedTests

XCTMain([
testCase(sanitizedTests.allTests),
testCase(SanitizedTests.allTests),
])
166 changes: 166 additions & 0 deletions Tests/SanitizedTests/SanitizedTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import XCTest
import HTTP
import Vapor

@testable import Sanitized

class SanitizedTests: XCTestCase {
static var allTests = [
("testBasic", testBasic),
("testBasicFailed", testBasicFailed),
("testPreValidateError", testPreValidateError),
("testPostValidateError", testPostValidateError),
("testPermitted", testPermitted),
("testEmptyPermitted", testEmptyPermitted),
]

func testBasic() {
let request = buildRequest(body: [
"id": 1,
"name": "Brett",
"email": "[email protected]"
])

expectNoThrow() {
let model: TestModel = try request.extractModel()
XCTAssertNil(model.id)
XCTAssertEqual(model.name, "Brett")
XCTAssertEqual(model.email, "[email protected]")
}
}

func testBasicFailed() {
let request = buildInvalidRequest()
expect(toThrow: Abort.badRequest) {
let _: TestModel = try request.extractModel()
}
}

func testPreValidateError() {
let request = buildRequest(body: [
"email": "[email protected]"
])

expect(toThrow: Abort.custom(status: .badRequest, message: "No name provided.")) {
let _: TestModel = try request.extractModel()
}
}

func testPostValidateError() {
let request = buildRequest(body: [
"id": 1,
"name": "Brett",
"email": "[email protected]"
])

let expectedError = Abort.custom(
status: .badRequest,
message: "Email must be longer than 8 characters."
)

expect(toThrow: expectedError) {
let _: TestModel = try request.extractModel()
}
}

func testPermitted() {
let json = JSON([
"id": 1,
"name": "Brett",
"email": "[email protected]"
])

let result = json.permit(["name"])
XCTAssertNil(result["id"])
XCTAssertEqual(result["name"]?.string, "Brett")
XCTAssertNil(result["email"])
}

func testEmptyPermitted() {
let json = JSON([
"id": 1,
"name": "Brett",
"email": "[email protected]"
])

let result = json.permit([])
XCTAssertNil(result["id"])
XCTAssertNil(result["name"])
XCTAssertNil(result["email"])
}
}

extension SanitizedTests {
func buildRequest(body: Node) -> Request {
let body = try! JSON(node: body).makeBytes()

return try! Request(
method: .post,
uri: "/test",
headers: [
"Content-Type": "application/json"
],
body: .data(body)
)
}

func buildInvalidRequest() -> Request {
return try! Request(
method: .post,
uri: "/test"
)
}
}

struct TestModel: Model, Sanitizable {
var id: Node?

var name: String
var email: String

static var permitted = ["name", "email"]

init(node: Node, in context: Context) throws {
id = node["id"]
name = try node.extract("name")
email = try node.extract("email")
}

func makeNode(context: Context) throws -> Node {
return .null
}

static func prepare(_ database: Database) throws {}
static func revert(_ database: Database) throws {}
}

extension TestModel {
static func updateThrownError(_ error: Error) -> AbortError {
return Abort.custom(status: .badRequest, message: "Username not provided.")
}

static func preValidate(data: JSON) throws {
guard data["name"]?.string != nil else {
throw Abort.custom(status: .badRequest, message: "No name provided.")
}

guard data["email"]?.string != nil else {
throw Abort.custom(status: .badRequest, message: "No email provided.")
}
}

func postValidate() throws {
guard email.count > 8 else {
throw Abort.custom(
status: .badRequest,
message: "Email must be longer than 8 characters."
)
}
}
}

extension Abort: Equatable {
static public func ==(lhs: Abort, rhs: Abort) -> Bool {
return lhs.code == rhs.code && lhs.message == rhs.message
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func expect<E: Error, ReturnType>(
let _ = try closure()
XCTFail("should have thrown", file: file, line: line)
} catch let error as E {
XCTAssertEqual(error, expectedError)
XCTAssertEqual(error, expectedError, file: file, line: line)
} catch {
XCTFail(
"expected type \(type(of: expectedError)) got \(type(of: error))",
Expand Down
Loading

0 comments on commit 1f9b85f

Please sign in to comment.