Skip to content

Commit

Permalink
make URLParameter keys type aware
Browse files Browse the repository at this point in the history
  • Loading branch information
khanlou committed Sep 6, 2020
1 parent e5b57c7 commit c4e2120
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 68 deletions.
22 changes: 16 additions & 6 deletions Demo/Todos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import Meridian
// Specs
// https://www.todobackend.com/specs/index.html?https://meridian-demo.herokuapp.com/todos

struct IDParameter: URLParameterKey {
public typealias DecodeType = String
}

extension ParameterKeys {
var id: IDParameter {
IDParameter()
}
}

struct ListTodos: Route {

static let route: RouteMatcher = .get(.root)
Expand Down Expand Up @@ -52,9 +62,9 @@ struct CreateTodo: Route {
}

struct ShowTodo: Route {
static let route: RouteMatcher = .get("/\(.id)")
static let route: RouteMatcher = .get("/\(\.id)")

@URLParameter(.id) var id: String
@URLParameter(\.id) var id

@EnvironmentObject var database: Database

Expand All @@ -74,9 +84,9 @@ struct TodoPatch: Codable {
}

struct EditTodo: Route {
static let route: RouteMatcher = .patch("/\(.id)")
static let route: RouteMatcher = .patch("/\(\.id)")

@URLParameter(.id) var id: String
@URLParameter(\.id) var id

@JSONBody var patch: TodoPatch

Expand All @@ -100,9 +110,9 @@ struct EditTodo: Route {
}

struct DeleteTodo: Route {
static let route: RouteMatcher = .delete("/\(.id)")
static let route: RouteMatcher = .delete("/\(\.id)")

@URLParameter(.id) var id: String
@URLParameter(\.id) var id

@EnvironmentObject var database: Database

Expand Down
8 changes: 4 additions & 4 deletions Meridian/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ let RequestErrorsKey = "RequestErrors"

public struct RequestContext {
public let header: RequestHeader
public let urlParameters: [URLParameterKey: Substring]
public let matchedRoute: MatchedRoute
public let postBody: Data

public init(header: RequestHeader, urlParameters: [URLParameterKey: Substring] = [:], postBody: Data = Data()) {
public init(header: RequestHeader, matchedRoute: MatchedRoute, postBody: Data = Data()) {
self.header = header
self.urlParameters = urlParameters
self.matchedRoute = matchedRoute
self.postBody = postBody
}

Expand Down Expand Up @@ -98,7 +98,7 @@ final class HTTPHandler: ChannelInboundHandler {

let requestContext = RequestContext(
header: header,
urlParameters: matchedRoute.parameters,
matchedRoute: matchedRoute,
postBody: body
)

Expand Down
19 changes: 4 additions & 15 deletions Meridian/Property Wrappers/URLParameter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,10 @@

import Foundation

public typealias URLParameter<Type: LosslessStringConvertible> = CustomWithParameters<URLParameterExtractor<Type>>
public typealias URLParameter<SpecificURLParameterKey: URLParameterKey> = CustomWithParameters<URLParameterExtractor<SpecificURLParameterKey>>

public struct URLParameterExtractor<Type: LosslessStringConvertible>: ParameterizedExtractor {

public static func extract(from context: RequestContext, parameters: URLParameterKey) throws -> Type {
guard let substring = _currentRequest.urlParameters[parameters] else {
throw MissingURLParameterError()
}
let value = String(substring)
if Type.self == String.self {
return value as! Type
} else if let finalValue = Type(value) {
return finalValue
} else {
throw URLParameterDecodingError(type: Type.self)
}
public struct URLParameterExtractor<SpecificURLParameterKey: URLParameterKey>: ParameterizedExtractor {
public static func extract(from context: RequestContext, parameters: KeyPath<ParameterKeys, SpecificURLParameterKey>) throws -> SpecificURLParameterKey.DecodeType {
try _currentRequest.matchedRoute.parameter(for: SpecificURLParameterKey.self)
}
}
43 changes: 23 additions & 20 deletions Meridian/RouteMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,26 @@
import Foundation

public struct MatchedRoute {
public let parameters: [URLParameterKey: Substring]
let parameters: [String: Substring]

public init(parameters: [URLParameterKey: Substring] = [:]) {
public init(parameters: [String: Substring] = [:]) {
self.parameters = parameters
}

public func parameter<Key: URLParameterKey>(for key: Key.Type) throws -> Key.DecodeType {
guard let substring = self.parameters[Key.stringKey] else {
throw MissingURLParameterError()
}
let value = String(substring)
if Key.DecodeType.self == String.self {
return value as! Key.DecodeType
} else if let finalValue = Key.DecodeType(value) {
return finalValue
} else {
throw URLParameterDecodingError(type: Key.DecodeType.self)
}

}
}

public struct RouteMatcher {
Expand Down Expand Up @@ -58,25 +73,13 @@ public struct RouteMatcher {
}
}

public struct URLParameterKey: Hashable {

public let id = UUID()

public init() {

}

public static let id = URLParameterKey()
}


extension RouteMatcher: ExpressibleByStringInterpolation {

public struct RegexMatcher: StringInterpolationProtocol {

var regexString = ""

var mapping: [URLParameterKey] = []
var mapping: [String] = []

public init(literalCapacity: Int, interpolationCount: Int) {

Expand All @@ -86,11 +89,11 @@ extension RouteMatcher: ExpressibleByStringInterpolation {
regexString.append(literal) // escape for regex
}

public mutating func appendInterpolation(_ urlParameter: URLParameterKey) {
public mutating func appendInterpolation<SpecificKey: URLParameterKey>(_ urlParameter: KeyPath<ParameterKeys, SpecificKey>) {

regexString.append("([^/]+)")

mapping.append(urlParameter)
mapping.append(SpecificKey.stringKey)
}
}

Expand All @@ -108,16 +111,16 @@ extension RouteMatcher: ExpressibleByStringInterpolation {
return nil
}

var result: [URLParameterKey: Substring] = [:]
var result: [String: Substring] = [:]

for match in matches {
let ranges = (0..<match.numberOfRanges)
.dropFirst() /*ignore the first match*/
.map({ match.range(at: $0) })

zip(stringInterpolation.mapping, ranges).forEach({ urlParameter, range in
zip(stringInterpolation.mapping, ranges).forEach({ urlParameterName, range in
guard let betterRange = Range(range, in: header.path) else { fatalError("Should be able to convert ranges") }
result[urlParameter] = header.path[betterRange]
result[urlParameterName] = header.path[betterRange]
})
}

Expand Down
22 changes: 22 additions & 0 deletions Meridian/URLParameterKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// URLParameterKey.swift
//
//
// Created by Soroush Khanlou on 9/6/20.
//

import Foundation

public enum ParameterKeys {

}

public protocol URLParameterKey {
associatedtype DecodeType: LosslessStringConvertible
}

extension URLParameterKey {
static var stringKey: String {
String(reflecting: Self.self)
}
}
10 changes: 5 additions & 5 deletions MeridianTests/RoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,27 +64,27 @@ final class RoutingTests: XCTestCase {
}

func testURLParameters() {
let matcher: RouteMatcher = "/testing/\(.tester)"
let matcher: RouteMatcher = "/testing/\(\.tester)"

let matchedRoute = matcher.matches(RequestHeader(method: .GET, uri: "/testing/123", headers: []))

XCTAssertNotNil(matchedRoute)

XCTAssertEqual(matchedRoute?.parameters[.tester], "123")
XCTAssertEqual(try matchedRoute?.parameter(for: TesterParameterKey.self), "123")

XCTAssertNil(matcher.matches(RequestHeader(method: .GET, uri: "/testing/", headers: [])))
XCTAssertNil(matcher.matches(RequestHeader(method: .GET, uri: "/testing/123/456", headers: [])))
}

func testTwoURLParameters() {
let matcher: RouteMatcher = "/testing/\(.tester)/sub/\(.secondTester)"
let matcher: RouteMatcher = "/testing/\(\.tester)/sub/\(\.secondTester)"

let matchedRoute = matcher.matches(RequestHeader(method: .GET, uri: "/testing/123/sub/hello", headers: []))

XCTAssertNotNil(matchedRoute)

XCTAssertEqual(matchedRoute?.parameters[.tester], "123")
XCTAssertEqual(matchedRoute?.parameters[.secondTester], "hello")
XCTAssertEqual(try matchedRoute?.parameter(for: TesterParameterKey.self), "123")
XCTAssertEqual(try matchedRoute?.parameter(for: SecondTesterParameterKey.self), "hello")

XCTAssertNil(matcher.matches(RequestHeader(method: .GET, uri: "/testing/123", headers: [])))
XCTAssertNil(matcher.matches(RequestHeader(method: .GET, uri: "/testing/123", headers: [])))
Expand Down
34 changes: 27 additions & 7 deletions MeridianTests/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ import Meridian

let http11 = HTTPVersion.init(major: 1, minor: 1)

extension URLParameterKey {
static let tester = URLParameterKey()
static let secondTester = URLParameterKey()
}

struct HTTPRequestBuilder {
let uri: String
let method: NIOHTTP1.HTTPMethod
Expand Down Expand Up @@ -103,6 +98,31 @@ enum MusicNote: String, Decodable {
}
}

extension URLParameterKey {
static let letter = URLParameterKey()
struct LetterParameterKey: URLParameterKey {
typealias DecodeType = LetterGrade
}

struct TesterParameterKey: URLParameterKey {
typealias DecodeType = String
}

struct SecondTesterParameterKey: URLParameterKey {
typealias DecodeType = String
}

struct StringIDParameter: URLParameterKey {
public typealias DecodeType = String
}

struct NumberParameter: URLParameterKey {
public typealias DecodeType = Int
}

extension ParameterKeys {
var letter: LetterParameterKey { .init() }
var tester: TesterParameterKey { .init() }
var secondTester: SecondTesterParameterKey { .init() }
var id: StringIDParameter { .init() }
var number: NumberParameter { .init() }
}

18 changes: 9 additions & 9 deletions MeridianTests/URLParameterRouteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,40 @@ struct NoURLParameterRoute: Route {
}

struct StringURLParameterRoute: Route {
static let route: RouteMatcher = "/string/\(.id)"
static let route: RouteMatcher = "/string/\(\.id)"

@URLParameter(.id) var id: String
@URLParameter(\.id) var id

func execute() throws -> Response {
"The ID is \(id)"
}
}

struct IntURLParameterRoute: Route {
static let route: RouteMatcher = "/int/\(.id)"
static let route: RouteMatcher = "/int/\(\.number)"

@URLParameter(.id) var id: Int
@URLParameter(\.number) var id

func execute() throws -> Response {
"The ID+1 is \(id+1)"
}
}

struct MultipleURLParameterRoute: Route {
static let route: RouteMatcher = "/int/\(.id)/letter/\(.letter)"
static let route: RouteMatcher = "/int/\(\.number)/letter/\(\.letter)"

@URLParameter(.id) var id: Int
@URLParameter(.letter) var letter: LetterGrade
@URLParameter(\.number) var id
@URLParameter(\.letter) var letter

func execute() throws -> Response {
"The ID+2 is \(id+2) and the letter is \(letter)"
}
}

struct LetterURLParameterRoute: Route {
static let route: RouteMatcher = "/letter/\(.letter)"
static let route: RouteMatcher = "/letter/\(\.letter)"

@URLParameter(.letter) var grade: LetterGrade
@URLParameter(\.letter) var grade

func execute() throws -> Response {
"The letter grade is \(grade)"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ extension URLParameters {
struct SampleEndpoint: Route {
static let path: RouteMatcher = "/api/users/\(.id))/followers"
static let path: RouteMatcher = "/api/users/\(\.id))/followers"
@QueryParameter("sort_direction") var sortDirection: SortDirection
@URLParameter(.id) var userID: String
@URLParameter(\.id) var userID
@EnivronmentObject var database: Database
Expand Down

0 comments on commit c4e2120

Please sign in to comment.