Skip to content

Commit

Permalink
fix: Correctly register Codable routes with trailing :id (#1485)
Browse files Browse the repository at this point in the history
* Ensure trailing :id is not duplicated
* Improve tests for :id param in Codable routes
  • Loading branch information
djones6 committed Sep 6, 2019
1 parent e486ad6 commit eb2b3b4
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 118 deletions.
24 changes: 12 additions & 12 deletions Sources/Kitura/CodableRouter+TypeSafeMiddleware.swift
Expand Up @@ -277,7 +277,7 @@ extension Router {
return
}
registerGetRoute(route: route, id: Id.self, outputType: O.self)
get(join(path: route, with: ":id")) { request, response, next in
get(appendId(path: route)) { request, response, next in
Log.verbose("Received GET (singular with identifier and middleware) type-safe request")
self.handleMiddleware(T.self, request: request, response: response) { typeSafeMiddleware in
guard let typeSafeMiddleware = typeSafeMiddleware else {
Expand Down Expand Up @@ -318,7 +318,7 @@ extension Router {
return
}
registerGetRoute(route: route, id: Id.self, outputType: O.self)
get(join(path: route, with: ":id")) { request, response, next in
get(appendId(path: route)) { request, response, next in
Log.verbose("Received GET (singular with identifier and middleware) type-safe request")
self.handleMiddleware(T1.self, T2.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2 else {
Expand Down Expand Up @@ -359,7 +359,7 @@ extension Router {
return
}
registerGetRoute(route: route, id: Id.self, outputType: O.self)
get(join(path: route, with: ":id")) { request, response, next in
get(appendId(path: route)) { request, response, next in
Log.verbose("Received GET (singular with identifier and middleware) type-safe request")
self.handleMiddleware(T1.self, T2.self, T3.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2, typeSafeMiddleware3 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2, let typeSafeMiddleware3 = typeSafeMiddleware3 else {
Expand Down Expand Up @@ -1069,7 +1069,7 @@ extension Router {
return
}
registerDeleteRoute(route: route, id: Id.self)
delete(join(path: route, with: ":id")) { request, response, next in
delete(appendId(path: route)) { request, response, next in
Log.verbose("Received DELETE (singular with middleware) type-safe request")
self.handleMiddleware(T.self, request: request, response: response) { typeSafeMiddleware in
guard let typeSafeMiddleware = typeSafeMiddleware else {
Expand Down Expand Up @@ -1108,7 +1108,7 @@ extension Router {
return
}
registerDeleteRoute(route: route, id: Id.self)
delete(join(path: route, with: ":id")) { request, response, next in
delete(appendId(path: route)) { request, response, next in
Log.verbose("Received DELETE (singular with middleware) type-safe request")
self.handleMiddleware(T1.self, T2.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2 else {
Expand Down Expand Up @@ -1147,7 +1147,7 @@ extension Router {
return
}
registerDeleteRoute(route: route, id: Id.self)
delete(join(path: route, with: ":id")) { request, response, next in
delete(appendId(path: route)) { request, response, next in
Log.verbose("Received DELETE (singular with middleware) type-safe request")
self.handleMiddleware(T1.self, T2.self, T3.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2, typeSafeMiddleware3 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2, let typeSafeMiddleware3 = typeSafeMiddleware3 else {
Expand Down Expand Up @@ -1738,7 +1738,7 @@ extension Router {
return
}
registerPutRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
put(join(path: route, with: ":id")) { request, response, next in
put(appendId(path: route)) { request, response, next in
Log.verbose("Received PUT type-safe request")
self.handleMiddleware(T.self, request: request, response: response) { typeSafeMiddleware in
guard let typeSafeMiddleware = typeSafeMiddleware else {
Expand Down Expand Up @@ -1779,7 +1779,7 @@ extension Router {
return
}
registerPutRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
put(join(path: route, with: ":id")) { request, response, next in
put(appendId(path: route)) { request, response, next in
Log.verbose("Received PUT type-safe request")
self.handleMiddleware(T1.self, T2.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2 else {
Expand Down Expand Up @@ -1820,7 +1820,7 @@ extension Router {
return
}
registerPutRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
put(join(path: route, with: ":id")) { request, response, next in
put(appendId(path: route)) { request, response, next in
Log.verbose("Received PUT type-safe request")
self.handleMiddleware(T1.self, T2.self, T3.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2, typeSafeMiddleware3 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2, let typeSafeMiddleware3 = typeSafeMiddleware3 else {
Expand Down Expand Up @@ -1872,7 +1872,7 @@ extension Router {
return
}
registerPatchRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
patch(join(path: route, with: ":id")) { request, response, next in
patch(appendId(path: route)) { request, response, next in
Log.verbose("Received PATCH type-safe request")
self.handleMiddleware(T.self, request: request, response: response) { typeSafeMiddleware in
guard let typeSafeMiddleware = typeSafeMiddleware else {
Expand Down Expand Up @@ -1919,7 +1919,7 @@ extension Router {
return
}
registerPatchRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
patch(join(path: route, with: ":id")) { request, response, next in
patch(appendId(path: route)) { request, response, next in
Log.verbose("Received PATCH type-safe request")
self.handleMiddleware(T1.self, T2.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2 else {
Expand Down Expand Up @@ -1965,7 +1965,7 @@ extension Router {
return
}
registerPatchRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
patch(join(path: route, with: ":id")) { request, response, next in
patch(appendId(path: route)) { request, response, next in
Log.verbose("Received PATCH type-safe request")
self.handleMiddleware(T1.self, T2.self, T3.self, request: request, response: response) { typeSafeMiddleware1, typeSafeMiddleware2, typeSafeMiddleware3 in
guard let typeSafeMiddleware1 = typeSafeMiddleware1, let typeSafeMiddleware2 = typeSafeMiddleware2, let typeSafeMiddleware3 = typeSafeMiddleware3 else {
Expand Down
21 changes: 16 additions & 5 deletions Sources/Kitura/CodableRouter.swift
Expand Up @@ -387,7 +387,7 @@ extension Router {
return
}
registerPutRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
put(join(path: route, with: ":id")) { request, response, next in
put(appendId(path: route)) { request, response, next in
Log.verbose("Received PUT type-safe request")
guard let identifier = CodableHelpers.parseIdOrSetResponseStatus(Id.self, from: request, response: response),
let codableInput = CodableHelpers.readCodableOrSetResponseStatus(I.self, from: request, response: response)
Expand All @@ -405,7 +405,7 @@ extension Router {
return
}
registerPatchRoute(route: route, id: Id.self, inputType: I.self, outputType: O.self)
patch(join(path: route, with: ":id")) { request, response, next in
patch(appendId(path: route)) { request, response, next in
Log.verbose("Received PATCH type-safe request")
guard let identifier = CodableHelpers.parseIdOrSetResponseStatus(Id.self, from: request, response: response),
let codableInput = CodableHelpers.readCodableOrSetResponseStatus(I.self, from: request, response: response)
Expand Down Expand Up @@ -536,7 +536,7 @@ extension Router {
return
}
registerGetRoute(route: route, id: Id.self, outputType: O.self)
get(join(path: route, with: ":id")) { request, response, next in
get(appendId(path: route)) { request, response, next in
Log.verbose("Received GET (singular with identifier) type-safe request")
guard let identifier = CodableHelpers.parseIdOrSetResponseStatus(Id.self, from: request, response: response) else {
next()
Expand Down Expand Up @@ -564,7 +564,7 @@ extension Router {
return
}
registerDeleteRoute(route: route, id: Id.self)
delete(join(path: route, with: ":id")) { request, response, next in
delete(appendId(path: route)) { request, response, next in
Log.verbose("Received DELETE (singular) type-safe request")
guard let identifier = CodableHelpers.parseIdOrSetResponseStatus(Id.self, from: request, response: response) else {
next()
Expand Down Expand Up @@ -641,7 +641,18 @@ extension Router {
}
}

internal func join(path base: String, with component: String) -> String {
/// Append the `:id` parameter to a path that does not already contain a parameter.
/// If the path already contains a parameter, it will be returned unmodified.
internal func appendId(path base: String) -> String {
let identifierSupplied = base.contains(":")
guard !identifierSupplied else {
return base
}
return joinPath(base, with: ":id")
}

/// Join two path components together such that they are separated by a single `/`.
private func joinPath(_ base: String, with component: String) -> String {
let strippedBase = base.hasSuffix("/") ? String(base.dropLast()) : base
let strippedComponent = component.hasPrefix("/") ? String(component.dropFirst()) : component
return "\(strippedBase)/\(strippedComponent)"
Expand Down
212 changes: 212 additions & 0 deletions Tests/KituraTests/TestCodablePathParams.swift
@@ -0,0 +1,212 @@
/**
* Copyright IBM Corporation 2017
*
* 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 XCTest
import Foundation
import KituraContracts

@testable import Kitura

final class TestCodablePathParams: KituraTest, KituraTestSuite {
static var allTests: [(String, (TestCodablePathParams) -> () throws -> Void)] {
return [
("testJoinPath", testJoinPath),
("testRouteWithTrailingSlash", testRouteWithTrailingSlash),
("testInvalidRouteParameters", testInvalidRouteParameters),
("testIdentifierNotExpected", testIdentifierNotExpected),
("testPartialIdentifierSupplied", testPartialIdentifierSupplied),
("testIdentifierNotSupplied", testIdentifierNotSupplied),
("testIdentifierSupplied", testIdentifierSupplied),
]
}

// Need to initialise to avoid compiler error
var router = Router()

// Reset for each test
override func setUp() {
super.setUp() // Initialize logging
router = Router()
}

struct Fruit: Codable, Equatable {
let name: String
let id: Int

static func == (lhs: Fruit, rhs: Fruit) -> Bool {
return lhs.name == rhs.name && lhs.id == rhs.id
}
}

func testJoinPath() {
let router = Router()
// Implicit append of :id
XCTAssertEqual(router.appendId(path: "a"), "a/:id")
XCTAssertEqual(router.appendId(path: "a/"), "a/:id")
// User already specified :id
XCTAssertEqual(router.appendId(path: "a/:id"), "a/:id")
// User specified a different identifier name (not supported)
XCTAssertEqual(router.appendId(path: "a/:foo"), "a/:foo")
}

// Test adding a trailing slash to your route when it has an implicit id parameter
func testRouteWithTrailingSlash() {
router.get("/fruit/") { (id: Int, respondWith: (Fruit?, RequestError?) -> Void) in
respondWith(Fruit(name: "apple", id: id), nil)
}
router.put("/fruit/") { (id: Int, fruit: Fruit, respondWith: (Fruit?, RequestError?) -> Void) in
respondWith(Fruit(name: fruit.name, id: id), nil)
}
router.patch("/fruit/") { (id: Int, fruit: Fruit, respondWith: (Fruit?, RequestError?) -> Void) in
respondWith(Fruit(name: fruit.name, id: id), nil)
}
router.delete("/fruit/") { (id: Int, respondWith: (RequestError?) -> Void) in
XCTAssertEqual(id, 1)
respondWith(nil)
}
let apple = Fruit(name: "apple", id: 1)
let banana = Fruit(name: "banana", id: 2)

buildServerTest(router, timeout: 30)
.request("get", path: "/fruit/1").hasStatus(.OK).hasContentType(withPrefix: "application/json").hasData(apple)
.request("put", path: "/fruit/2", data: banana).hasStatus(.OK).hasContentType(withPrefix: "application/json").hasData(banana)
.request("patch", path: "/fruit/2", data: banana).hasStatus(.OK).hasContentType(withPrefix: "application/json").hasData(banana)
.request("delete", path: "/fruit/1").hasStatus(.noContent).hasNoData()
.run()
}

// Test that routes containing a path parameter that is not `:id` are
// correctly rejected.
func testInvalidRouteParameters() {
// These routes should fail to be registered.
router.get("/fruit/:myid") { (id: Int, respondWith: (Fruit?, RequestError?) -> Void) in
XCTFail("GET on /fruit/:myid that should not happen")
let result = Fruit(name: "banana", id: 1)
respondWith(result, nil)
}
router.delete("/fruit/:myid") { (id: Int, respondWith: (RequestError?) -> Void) in
XCTFail("DELETE on /fruit/:myid that should not happen")
respondWith(.badRequest)
}

buildServerTest(router, timeout: 30)
.request("get", path: "/fruit/1")
.hasStatus(.notFound)
.hasData()

.request("delete", path: "/fruit/1")
.hasStatus(.notFound)
.hasData()

.run()
}

// Test that routes with trailing :id for a DELETE (plural) or GET (plural)
// handler are correctly rejected.
func testIdentifierNotExpected() {
// These routes should fail to be registered.
router.delete("/fruit/:id") { (respondWith: (RequestError?) -> Void) in
XCTFail("DELETE (plural) on /fruit/:id that should not happen")
respondWith(.badRequest)
}
router.get("/fruit/:id") { (respondWith: ([Fruit]?, RequestError?) -> Void) in
XCTFail("GET (plural) on /fruit/:id that should not happen")
}

buildServerTest(router, timeout: 30)
.request("delete", path: "/fruit/1")
.hasStatus(.notFound)
.hasData()

.request("get", path: "/fruit/1")
.hasStatus(.notFound)
.hasData()

.run()
}

// Test that a route with a partial identifier is rejected.
func testPartialIdentifierSupplied() {
// This route should fail to be registered.
router.delete("/status/:") { (id: Int, respondWith: (RequestError?) -> Void) in
XCTFail("DELETE on /status/: that should not happen")
respondWith(.badRequest)
}

buildServerTest(router, timeout: 30)
.request("delete", path: "/status/1")
.hasStatus(.notFound)
.hasData()
.run()
}

// A trailing :id parameter is allowed, but if not supplied will be added
// implicitly (with a warning to signal to the user that this has occurred).
func testIdentifierNotSupplied() {
router.delete("/fruit/") { (id: Int, respondWith: (RequestError?) -> Void) in
print("DELETE on /fruit/ (implicit :id)")
XCTAssertEqual(id, 1)
respondWith(nil)
}
router.get("/fruit") { (id: Int, respondWith: (Fruit?, RequestError?) -> Void) in
print("GET on /fruit (implicit :id)")
respondWith(Fruit(name: "banana", id: id), nil)
}

let banana = Fruit(name: "banana", id: 10)

buildServerTest(router, timeout: 30)
.request("delete", path: "/fruit/1")
.hasStatus(.noContent)
.hasNoData()

.request("get", path: "/fruit/10")
.hasStatus(.OK)
.hasData(banana)

.run()
}

// Test added to address fix for https://github.com/IBM-Swift/Kitura/issues/1473
// Tests that a GET (singular) or DELETE (singular) with explicit :id parameter
// is successful.
// A trailing :id parameter is allowed, and replaces the :id parameter that
// would otherwise be added implicitly.
func testIdentifierSupplied() {
router.get("/fruit/:id") { (id: Int, respondWith: (Fruit?, RequestError?) -> Void) in
print("GET on /fruit/:id")
respondWith(Fruit(name: "banana", id: id), nil)
}
router.delete("/fruit/:id") { (id: Int, respondWith: (RequestError?) -> Void) in
print("DELETE on /fruit/:id")
XCTAssertEqual(id, 1)
respondWith(nil)
}
let banana = Fruit(name: "banana", id: 20)

buildServerTest(router, timeout: 30)
.request("get", path: "/fruit/20")
.hasStatus(.OK)
.hasData(banana)

.request("delete", path: "/fruit/1")
.hasStatus(.noContent)
.hasNoData()

.run()
}

}

0 comments on commit eb2b3b4

Please sign in to comment.