├── .gitignore ├── Package.swift ├── Package.resolved ├── LICENSE ├── Tests └── URLFormatTests │ └── URLFormatTests.swift ├── README.md └── Sources └── URLFormat └── URLFormat.swift /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .swiftpm 4 | xcuserdata 5 | *.xcodeproj 6 | DerivedData/ 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "URLFormat", 6 | products: [ 7 | .library(name: "URLFormat", targets: ["URLFormat"]), 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/ilyapuchka/common-parsers.git", .branch("master")) 11 | ], 12 | targets: [ 13 | .target(name: "URLFormat", dependencies: ["CommonParsers"]), 14 | .testTarget(name: "URLFormatTests", dependencies: ["URLFormat"]), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CommonParsers", 6 | "repositoryURL": "https://github.com/ilyapuchka/common-parsers.git", 7 | "state": { 8 | "branch": "master", 9 | "revision": "fb6f45cc44c4bd3b1385bf57e67755944e405ede", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "Prelude", 15 | "repositoryURL": "https://github.com/pointfreeco/swift-prelude.git", 16 | "state": { 17 | "branch": "master", 18 | "revision": "c61b49392768a6fa90fe9508774cf90a80061c8b", 19 | "version": null 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ilya Puchka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/URLFormatTests/URLFormatTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import URLFormat 3 | import Prelude 4 | 5 | class URLFormatTests: XCTestCase { 6 | 7 | func testFormats() throws { 8 | var url: URLComponents 9 | 10 | let f1 = "GET"/.user/.string/.repos/?.filter(.string)&.page(.int) 11 | 12 | url = URLComponents(string: "user/ilya/repos?filter=swift&page=2")! 13 | var match = try XCTUnwrap(f1.parse(URLRequestComponents(method: "GET", urlComponents: url))) 14 | XCTAssertEqual("\(flatten(match))", #"("ilya", "swift", 2)"#) 15 | XCTAssertEqual(try f1.print(parenthesize("ilya", "swift", 2))?.urlComponents, url) 16 | 17 | url = URLComponents(string: "/user/ilya/repos?page=2&filter=swift")! 18 | match = try XCTUnwrap(f1.parse(URLRequestComponents(method: "GET", urlComponents: url))) 19 | XCTAssertEqual("\(flatten(match))", #"("ilya", "swift", 2)"#) 20 | 21 | let f2: URLFormat = ""/.helloworld 22 | 23 | url = URLComponents(string: "helloworld")! 24 | let match2 = try XCTUnwrap(f2.parse(URLRequestComponents(urlComponents: url))) 25 | XCTAssertEqual(try f2.print(match2)?.urlComponents, url) 26 | 27 | url = URLComponents(string: "helloworld/foo")! 28 | try XCTAssertNil(f2.parse(URLRequestComponents(urlComponents: url))) 29 | 30 | let f3 = ""/.hello/.string/?.name(.string) 31 | 32 | url = URLComponents(string: "hello/user?name=ilya")! 33 | let match3 = try XCTUnwrap(f3.parse(URLRequestComponents(urlComponents: url))) 34 | XCTAssertEqual("\(match3)", #"("user", "ilya")"#) 35 | XCTAssertEqual(try f3.print(match3)?.urlComponents, url) 36 | 37 | let f4 = ""/.hello/?.name(.string)&.page(.int) 38 | 39 | url = URLComponents(string: "hello?name=ilya&page=2")! 40 | let match4 = try XCTUnwrap(f4.parse(URLRequestComponents(urlComponents: url))) 41 | XCTAssertEqual("\(match4)", #"("ilya", 2)"#) 42 | XCTAssertEqual(try f4.print(match4)?.urlComponents, url) 43 | 44 | let f5 = ""/.hello/?.name(.string) 45 | 46 | url = URLComponents(string: "hello?name=ilya&page=2")! 47 | let match5 = try XCTUnwrap(f5.parse(URLRequestComponents(urlComponents: url))) 48 | XCTAssertEqual("\(match5)", #"ilya"#) 49 | 50 | url = URLComponents(string: "hello/ilya/world?name=ilya&page=2")! 51 | try XCTAssertNil(f5.parse(URLRequestComponents(urlComponents: url))) 52 | 53 | let f6 = ""/.user/.string* 54 | 55 | url = URLComponents(string: "user/ilya/?page=2")! 56 | var match6 = try XCTUnwrap(f6.parse(URLRequestComponents(urlComponents: url))) 57 | XCTAssertEqual("\(match6)", #"("ilya", "")"#) 58 | 59 | url = URLComponents(string: "user/ilya/repo/?page=2")! 60 | match6 = try XCTUnwrap(f6.parse(URLRequestComponents(urlComponents: url))) 61 | XCTAssertEqual("\(match6)", #"("ilya", "repo/")"#) 62 | 63 | let f7 = ""/.hello*?.name(.string) 64 | 65 | url = URLComponents(string: "hello?name=ilya&page=2")! 66 | var match7 = try XCTUnwrap(f7.parse(URLRequestComponents(urlComponents: url))) 67 | XCTAssertEqual("\(match7)", #"("", "ilya")"#) 68 | 69 | url = URLComponents(string: "hello/world/?name=ilya&page=2")! 70 | match7 = try XCTUnwrap(f7.parse(URLRequestComponents(urlComponents: url))) 71 | XCTAssertEqual("\(match7)", #"("world/", "ilya")"#) 72 | } 73 | 74 | func testURLRequestFormat() throws { 75 | let POST = ClosedPathFormat(httpMethod("POST")) 76 | let f1: URLFormat<((String, String), Int)> = POST/.user/.string/.repos/?.filter(.string)&.page(.int) 77 | 78 | let request = URLRequestComponents( 79 | method: "POST", 80 | urlComponents: URLComponents(string: "user/ilya/repos?filter=swift&page=2")! 81 | ) 82 | let match = try XCTUnwrap(f1.parse(request)) 83 | XCTAssertEqual("\(flatten(match))", #"("ilya", "swift", 2)"#) 84 | XCTAssertEqual(try f1.print(parenthesize("ilya", "swift", 2))?.urlComponents, request.urlComponents) 85 | XCTAssertEqual(try f1.print(parenthesize("ilya", "swift", 2))?.method, "POST") 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URLFormat 2 | 3 | Type safe url pattern matching without regular expressions and argument type mismatches based on parser combinators. 4 | 5 | Example: 6 | 7 | ```swift 8 | let format: URLFormat = ""/.users/.string/.repos/?.filter(.string)&.page(.int) 9 | let url = URLComponents(string: "/users/apple/repos/?filter=swift&page=2")! 10 | let request = URLRequestComponents(urlComponents: url) 11 | let parameters = try format.parse(request) 12 | 13 | _ = flatten(parameters) // ("apple", "swift", 2) 14 | try format.print(parameters) // "users/apple/repos?filter=swift&page=2" 15 | try format.template(parameters) // "users/:String/repos?filter=:String&page=:Int" 16 | ``` 17 | 18 | This library is based on [CommonParsers](https://github.com/ilyapuchka/common-parsers) which provides a common foundation for parser combinators and is heavily inspired by [swift-parser-printer](https://github.com/pointfreeco/swift-parser-printer) from [pointfreeco](https://twitter.com/pointfreeco). If you want to learn more about [parser combinators](https://www.pointfree.co/episodes/ep62-parser-combinators-part-1) and application of functional concepts in every day iOS development check out their [blog](https://www.pointfree.co). 19 | 20 | URLFormat is used in [SwiftNIOMock](https://github.com/ilyapuchka/SwiftNIOMock) to implement URL router. 21 | 22 | To use URLFormat with Vapor use a dedicated "vapor" branch ([Read more](#Vapor)) 23 | 24 | Also checkout [Interplate](https://github.com/ilyapuchka/Interplate) which provides a foundation for string templates using parser combinators and string interpolation together. 25 | 26 | ## Usage 27 | 28 | URLFormat is a URL builder that allows you to describe URL in a natural manner and allows you to pattern match it in a type safe way. 29 | 30 | The conventional way of representing URL patterns, i.e. for web server API routes, is using some kind of string placeholder for parameters, i.e. `/user/:name`. This is then parsed, and path and query parameters are aggregated into a collection. The issue is that this approach is error-prone (what if `:` is missed) and access to the parameters is not type safe - it's possible to access parameters as a wrong type or conversion must be implemented by the client, and it's possible to access parameter by the wrong key or index. 31 | 32 | Another approach that Swift allows is to use enums pattern matching, as described in [this post](https://alisoftware.github.io/swift/pattern-matching/2015/08/23/urls-and-pattern-matching/) and implemented in [URLPatterns](https://github.com/johnpatrickmorgan/URLPatterns). While this approach allows type-safe access to parameters it's not very ergonomic and nice to read: 33 | 34 | ```swift 35 | if case .n4("user", let userId, "profile", _) ~= url.countedPathElements() { ... } 36 | ``` 37 | 38 | Another downside of this approach is that it only allows to extract parameters of the same type, so most of the time you would extract all of them as `String` and convert to other types: 39 | 40 | ```swift 41 | case chat(room: String, membersCount: Int) 42 | 43 | case .n3("chat", let room, let membersCount): 44 | self = .chat(room: room, membersCount: number) // Cannot convert value of type 'String' to expected argument type 'Int' 45 | ``` 46 | 47 | In Vapor routes are defined as a collection of path components: 48 | 49 | ```swift 50 | router.get("users", String.parameter) { req in 51 | let name = try req.parameters.next(String.self) 52 | return "User #\(name)" 53 | } 54 | ``` 55 | 56 | You can as well use string placeholders for parameters: 57 | 58 | ```swift 59 | router.get("users", ":name") { request in 60 | guard let userName = request.parameters["name"]?.string else { 61 | throw Abort.badRequest 62 | } 63 | 64 | return "You requested User #\(userName)" 65 | } 66 | ``` 67 | 68 | This is nicer to write and read, but it's even less type safe - the parameters must be fetched in the order they appear in the path and their types should match but the compiler won't ensure that and you would need to make sure that the pattern definition and parameter access are always in sync. 69 | 70 | You also can't describe query parameters in the route, they are instead accessed in the route handler either via `request.data["key"]?.string` or `request.query?["key"]?.stirng` which is also not type safe. 71 | 72 | With URLFormat you would describe URLs as follows: 73 | 74 | ```swift 75 | let urlFormat: URLFormat = ""/.users/.string/.repos/?.filter(.string)&.page(.int) 76 | let url = URLComponents(string: "/users/apple/repos/?filter=swift&page=2")! 77 | let request = URLRequestComponents(urlComponents: url) 78 | let parameters = urlFormat.parse(request) 79 | print(flatten(parameters)) // ("apple", "swift", 2) 80 | ``` 81 | 82 | This pattern will match URL with path like `/users/apple/repos/?filter=swift&page=1` (first and last `/` are optional). The fully qualified type of `urlFormat` in this case would be `ClosedQueryFormat<((String, String), Int)>` (most of the time using base class type `URLFormat` is sufficient). The type of generic parameter describes the types of all captured parameters. To extract them from the actual URL you'd use `parse` method and one of `flatten` functions to "flatten" nested tuples, i.e. `((A, B), C) -> (A, B, C)` which makes it more convenient to access parameters. 83 | 84 | Note that it's not necessary to specify a generic type parameter manually as the compiler can infer it from the declaration[1](#f1). And the compiler ensures that pattern and types of captured parameters are always in sync. 85 | 86 | A nice caveat is that `URLFormat` can be used to print actual URLs and their readable templates if you provide it values for its parameters (again the compiler makes sure that they are always in sync): 87 | 88 | ```swift 89 | let parameters = parenthesize("apple", "swift", 2) 90 | urlFormat.print(parameters) // "users/apple/repos?filter=swift&page=2" 91 | urlFormat.template(parameters) // "users/:String/repos?filter=:String&page=:Int" 92 | ``` 93 | 94 | Note that there are no string literals involved in declaring this URL except the first one. This is because under the hood `URLFormat` implements `@dynamicMemberLookup`, so an expression like `.users` is converted to the parser that parses `"users"` string from the path components. 95 | 96 | You can either leave the first string component empty[2](#f2) or use it to specify the HTTP method of the request if you use URLFormat with HTTP requests and not just URLs: 97 | 98 | ```swift 99 | let urlFormat: URLFormat = "GET"/.users/.string/.repos/?.filter(.string)&.page(.int) 100 | let url = URLComponents(string: "/users/apple/repos/?filter=swift&page=2")! 101 | let request = URLRequestComponents(method: "GET", urlComponents: url) 102 | let parameters = urlFormat.parse(request) 103 | urlFormat.print(parameters) // "GET users/apple/repos?filter=swift&page=2" 104 | ``` 105 | 106 | Path parameters are parsed using `.string` and `.int` operators. Query parameters are parsed with a combination of these operators and dynamic member lookup, so `.filter(.string)` will parse a string query parameter named `"filter"`, `.page(.int)` will parse an integer query parameter named `"page"`. 107 | 108 | URLFormat also makes sure that URL is composed of path and query components correctly by allowing usage of `/`, `/?`, `&`, `*` and `*?` operators only in the correct places. This is done by using different subclasses of `URLFormat` to keep track of the builder state. It is similar to using phantom generic type parameters but allows to implement dynamic member lookup only for specific states of the builder. 109 | 110 | 1: an exeption here is when pattern does not capture any parameters, i.e. `_ = URLFormat = ""/.helloworld` . `Prelude.Unit` here is a type, similar to `Void`, but unlike `Void` it is an actual empty struct type. [↩](#a1) 111 | 112 | 2: String in the beginning of the pattern is needed because static `dynamicMemberLookup` subscript calls can't be infered without explicitly specifying type in the beginning of expression (see [this discussion](https://forums.swift.org/t/static-dynamicmemberlookup/33310/5) for details) [↩](#a2) 113 | 114 | ## Parameters types 115 | 116 | Following parameters types are supported: 117 | 118 | - `String` with `.string` operator 119 | - `Character` with `.char` operator 120 | - `Int` with `.int` operator 121 | - `Double` with `.double` operator 122 | - `Bool` with `.bool` operator 123 | - `UUID` with `.uuid` operator 124 | - `Any` with `.any` operator (unlike `*` this will match only single path component, `*` will capture all trailing path components into one string) 125 | - `LosslessStringConvertible` types with `lossless(MyType.self)` operator 126 | - `RawRepresentable` with `String`, `Character`, `Int` and `Double` raw value types with `raw(MyType.self)` operator 127 | 128 | In rare cases where your URL path components collide with these operator names you can use a `.const` operator to define path component as a string literal and not a typed parameter: 129 | 130 | ```swift 131 | /.users/.const("uuid")/.uuid 132 | ``` 133 | 134 | You can add support for your own types by implementing `PartialIso`: 135 | 136 | ```swift 137 | import CommonParsers 138 | 139 | extension URLPartialIso where A == String, B == MyType { 140 | static var myType: URLPartialIso { ... } 141 | } 142 | extension OpenPathFormat where A == Prelude.Unit { 143 | var myType: ClosedPathFormat { 144 | return ClosedPathFormat(parser %> path(.myType)) 145 | } 146 | } 147 | extension OpenPathFormat { 148 | var myType: ClosedPathFormat<(A, MyType)> { 149 | return ClosedPathFormat(parser <%> path(.myType)) 150 | } 151 | } 152 | ``` 153 | 154 | With that you can use your type as a path or a query parameter: 155 | 156 | `""/.users/.myType/.repos/?.filter(.myType)&.page(.int)` 157 | 158 | ## Operators 159 | 160 | `/` - concatenates two path components 161 | `/?` - concatenates path with a query component 162 | `&` - concatenates two query components 163 | `*` - allows any trailing path components 164 | `*?` - concatenates path with any trailing path components and a query component 165 | 166 | ## Vapor 167 | 168 | To use URLFormat with Vapor you need to install it from the "vapor" branch . Then you can use `import VaporURLFormat` instead of `import URLFormat` and register routes using `router` method instead of `get`, `post`, `put` etc.: 169 | 170 | ```swift 171 | router.route(GET/.hello/.string) { (request, string) in 172 | print(string) // "vapor" 173 | ... 174 | } 175 | try app.client(.GET, "/hello/vapor") 176 | ``` 177 | 178 | With Swift 5.2 you can use router as a function directly (using Swift static callable feature): 179 | 180 | ```swift 181 | router(GET/.hello/.string) { (request, string) in 182 | print(string) // "vapor" 183 | ... 184 | } 185 | try app.client(.GET, "/hello/vapor") 186 | ``` 187 | 188 | ## Installation 189 | 190 | ```swift 191 | import PackageDescription 192 | 193 | let package = Package( 194 | dependencies: [ 195 | .package(url: "https://github.com/ilyapuchka/URLFormat.git", .branch("master")), 196 | ] 197 | ) 198 | ``` 199 | 200 | For using URLFormat with Vapor: 201 | 202 | ```swift 203 | import PackageDescription 204 | 205 | let package = Package( 206 | dependencies: [ 207 | .package(url: "https://github.com/ilyapuchka/URLFormat.git", .branch("vapor")), 208 | ] 209 | ) 210 | ``` 211 | -------------------------------------------------------------------------------- /Sources/URLFormat/URLFormat.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Prelude 3 | import CommonParsers 4 | 5 | public struct URLRequestComponents: Monoid, CustomStringConvertible { 6 | public let method: String 7 | public internal(set) var urlComponents: URLComponents 8 | 9 | public init(method: String = "", urlComponents: URLComponents = URLComponents()) { 10 | self.urlComponents = urlComponents 11 | self.method = method 12 | } 13 | 14 | public static var empty: URLRequestComponents = URLRequestComponents() 15 | 16 | public var isEmpty: Bool { 17 | return pathComponents.isEmpty && urlComponents.scheme == nil && urlComponents.host == nil 18 | } 19 | 20 | public static func <> (lhs: URLRequestComponents, rhs: URLRequestComponents) -> URLRequestComponents { 21 | var result = URLComponents() 22 | result.scheme = lhs.urlComponents.scheme ?? rhs.urlComponents.scheme 23 | result.host = lhs.urlComponents.host ?? rhs.urlComponents.host 24 | result.path = [lhs.urlComponents.path, rhs.urlComponents.path] 25 | .filter { !$0.isEmpty } 26 | .joined(separator: "/") 27 | 28 | if lhs.urlComponents.host != nil && rhs.urlComponents.host == nil { 29 | result.path = "/" + result.path 30 | } 31 | 32 | result.queryItems = 33 | lhs.urlComponents.queryItems.flatMap { lhs in 34 | rhs.urlComponents.queryItems.flatMap { rhs in lhs + rhs } 35 | ?? lhs 36 | } 37 | ?? rhs.urlComponents.queryItems 38 | return .init(method: lhs.method, urlComponents: result) 39 | } 40 | 41 | public var pathComponents: [String] { 42 | get { 43 | if urlComponents.path.isEmpty { 44 | return [] 45 | } else if urlComponents.path.hasPrefix("/") { 46 | return urlComponents.path.dropFirst().components(separatedBy: "/") 47 | } else { 48 | return urlComponents.path.components(separatedBy: "/") 49 | } 50 | } 51 | set { 52 | urlComponents.path = newValue.joined(separator: "/") 53 | } 54 | } 55 | 56 | func with(_ f: (inout URLRequestComponents) -> Void) -> URLRequestComponents { 57 | var v = self 58 | f(&v) 59 | return v 60 | } 61 | 62 | public var description: String { 63 | if !method.isEmpty { 64 | return "\(method) \(urlComponents)" 65 | } else { 66 | return "\(urlComponents)" 67 | } 68 | } 69 | } 70 | 71 | public class URLFormat { 72 | public let parser: Parser 73 | 74 | required init(_ parser: Parser) { 75 | self.parser = parser 76 | } 77 | 78 | public func parse(_ url: URLRequestComponents) throws -> A? { 79 | fatalError() 80 | } 81 | 82 | public func print(_ value: A) throws -> URLRequestComponents? { 83 | try parser.print(value) 84 | } 85 | 86 | public func template() throws -> URLRequestComponents? { 87 | try parser.templateValue().flatMap(parser.template) 88 | } 89 | } 90 | 91 | // url does not have query and is open for adding more path/query parameters 92 | @dynamicMemberLookup 93 | public class OpenPathFormat: URLFormat { 94 | public subscript(dynamicMember member: String) -> ClosedPathFormat { 95 | return ClosedPathFormat(parser <% path(member)) 96 | } 97 | public override func parse(_ url: URLRequestComponents) throws -> A? { 98 | try self.end.parser.parse(url)?.match 99 | } 100 | } 101 | 102 | // url does not have a query and is complete, not expeciting any path parameters 103 | // only query parameters can be added 104 | public class ClosedPathFormat: URLFormat, ExpressibleByStringLiteral { 105 | public required init(_ parser: Parser) { 106 | super.init(parser) 107 | } 108 | required public convenience init(stringLiteral value: String) { 109 | self.init(httpMethod(value).map(.any)) 110 | } 111 | public override func parse(_ url: URLRequestComponents) throws -> A? { 112 | try self.end.parser.parse(url)?.match 113 | } 114 | } 115 | 116 | // url has a query and is open for adding more query parameters 117 | // no path parameters can be added 118 | @dynamicMemberLookup 119 | public class OpenQueryFormat: URLFormat { 120 | public subscript(dynamicMember member: String) -> (URLPartialIso) -> ClosedQueryFormat<(A, B)> { 121 | return { [parser] iso in 122 | return ClosedQueryFormat(parser <%> query(member, iso)) 123 | } 124 | } 125 | public subscript(dynamicMember member: String) -> (URLPartialIso) -> ClosedQueryFormat where A == Prelude.Unit { 126 | return { [parser] iso in 127 | return ClosedQueryFormat(parser %> query(member, iso)) 128 | } 129 | } 130 | public subscript(dynamicMember member: String) -> (URLPartialIso) -> ClosedQueryFormat<(A, B)> where B.RawValue == Int { 131 | return { [parser] iso in 132 | return ClosedQueryFormat(parser <%> query(member, .int >>> .raw(B.self))) 133 | } 134 | } 135 | public subscript(dynamicMember member: String) -> (URLPartialIso) -> ClosedQueryFormat<(A, B)> where B.RawValue == Double { 136 | return { [parser] iso in 137 | return ClosedQueryFormat(parser <%> query(member, .double >>> .raw(B.self))) 138 | } 139 | } 140 | public subscript(dynamicMember member: String) -> (URLPartialIso) -> ClosedQueryFormat<(A, B)> where B.RawValue == Character { 141 | return { [parser] iso in 142 | return ClosedQueryFormat(parser <%> query(member, .char >>> .raw(B.self))) 143 | } 144 | } 145 | public override func parse(_ url: URLRequestComponents) throws -> A? { 146 | try parser.parse(url)?.match 147 | } 148 | } 149 | 150 | // url has a query and is complete, not expecting any query parameters 151 | // no query parameters can be added 152 | public class ClosedQueryFormat: URLFormat { 153 | public override func parse(_ url: URLRequestComponents) throws -> A? { 154 | try parser.parse(url)?.match 155 | } 156 | } 157 | 158 | postfix operator / 159 | public postfix func / (_ lhs: ClosedPathFormat) -> OpenPathFormat { 160 | return OpenPathFormat(lhs.parser) 161 | } 162 | 163 | postfix operator /? 164 | public postfix func /? (_ lhs: ClosedPathFormat) -> OpenQueryFormat { 165 | return OpenQueryFormat(lhs.end.parser) 166 | } 167 | 168 | postfix operator *? 169 | public postfix func *? (_ lhs: ClosedPathFormat) -> OpenQueryFormat<(A, String)> { 170 | return OpenQueryFormat(lhs.parser <%> some()) 171 | } 172 | 173 | public postfix func *? (_ lhs: ClosedPathFormat) -> OpenQueryFormat { 174 | return OpenQueryFormat(lhs.parser %> some()) 175 | } 176 | 177 | postfix operator & 178 | public postfix func & (_ lhs: ClosedQueryFormat) -> OpenQueryFormat { 179 | return OpenQueryFormat(lhs.parser) 180 | } 181 | 182 | postfix operator * 183 | public postfix func * (_ lhs: ClosedPathFormat) -> URLFormat<(A, String)> { 184 | return ClosedQueryFormat(lhs.parser <%> some()) 185 | } 186 | 187 | public postfix func * (_ lhs: ClosedPathFormat) -> URLFormat { 188 | return ClosedQueryFormat(lhs.parser %> some()) 189 | } 190 | 191 | extension OpenPathFormat { 192 | public func const(_ value: String) -> ClosedPathFormat { 193 | return ClosedPathFormat(parser <% path(value)) 194 | } 195 | public var string: ClosedPathFormat<(A, String)> { 196 | return ClosedPathFormat(parser <%> path(.string)) 197 | } 198 | public var char: ClosedPathFormat<(A, Character)> { 199 | return ClosedPathFormat(parser <%> path(.char)) 200 | } 201 | public var bool: ClosedPathFormat<(A, Bool)> { 202 | return ClosedPathFormat(parser <%> path(.bool)) 203 | } 204 | public var int: ClosedPathFormat<(A, Int)> { 205 | return ClosedPathFormat(parser <%> path(.int)) 206 | } 207 | public var double: ClosedPathFormat<(A, Double)> { 208 | return ClosedPathFormat(parser <%> path(.double)) 209 | } 210 | public var uuid: ClosedPathFormat<(A, UUID)> { 211 | return ClosedPathFormat(parser <%> path(.uuid)) 212 | } 213 | public var any: ClosedPathFormat<(A, Any)> { 214 | return ClosedPathFormat(parser <%> path(.any)) 215 | } 216 | public func lossless(_ type: B.Type) -> ClosedPathFormat<(A, B)> { 217 | return ClosedPathFormat(parser <%> path(.losslessStringConvertible)) 218 | } 219 | public func raw(_ type: B.Type) -> ClosedPathFormat<(A, B)> where B.RawValue == String { 220 | return ClosedPathFormat(parser <%> path(.string).map(.rawRepresentable)) 221 | } 222 | public func raw(_ type: B.Type) -> ClosedPathFormat<(A, B)> where B.RawValue == Int { 223 | return ClosedPathFormat(parser <%> path(.int).map(.rawRepresentable)) 224 | } 225 | public func raw(_ type: B.Type) -> ClosedPathFormat<(A, B)> where B.RawValue == Double { 226 | return ClosedPathFormat(parser <%> path(.double).map(.rawRepresentable)) 227 | } 228 | public func raw(_ type: B.Type) -> ClosedPathFormat<(A, B)> where B.RawValue == Character { 229 | return ClosedPathFormat(parser <%> path(.char).map(.rawRepresentable)) 230 | } 231 | } 232 | 233 | extension OpenPathFormat where A == Prelude.Unit { 234 | public func const(_ value: String) -> ClosedPathFormat { 235 | return ClosedPathFormat(parser <% path(value)) 236 | } 237 | public var string: ClosedPathFormat { 238 | return ClosedPathFormat(parser %> path(.string)) 239 | } 240 | public var char: ClosedPathFormat { 241 | return ClosedPathFormat(parser %> path(.char)) 242 | } 243 | public var bool: ClosedPathFormat { 244 | return ClosedPathFormat(parser %> path(.bool)) 245 | } 246 | public var int: ClosedPathFormat { 247 | return ClosedPathFormat(parser %> path(.int)) 248 | } 249 | public var double: ClosedPathFormat { 250 | return ClosedPathFormat(parser %> path(.double)) 251 | } 252 | public var uuid: ClosedPathFormat { 253 | return ClosedPathFormat(parser %> path(.uuid)) 254 | } 255 | public var any: ClosedPathFormat { 256 | return ClosedPathFormat(parser %> path(.any)) 257 | } 258 | public func lossless(_ type: B.Type) -> ClosedPathFormat { 259 | return ClosedPathFormat(parser %> path(.losslessStringConvertible)) 260 | } 261 | public func raw(_ type: B.Type) -> ClosedPathFormat where B.RawValue == String { 262 | return ClosedPathFormat(parser %> path(.string).map(.rawRepresentable)) 263 | } 264 | public func raw(_ type: B.Type) -> ClosedPathFormat where B.RawValue == Int { 265 | return ClosedPathFormat(parser %> path(.int).map(.rawRepresentable)) 266 | } 267 | public func raw(_ type: B.Type) -> ClosedPathFormat where B.RawValue == Double { 268 | return ClosedPathFormat(parser %> path(.double).map(.rawRepresentable)) 269 | } 270 | public func raw(_ type: B.Type) -> ClosedPathFormat where B.RawValue == Character { 271 | return ClosedPathFormat(parser %> path(.char).map(.rawRepresentable)) 272 | } 273 | } 274 | 275 | extension URLPartialIso where A == String, B: LosslessStringConvertible & CustomParserTemplateValueConvertible { 276 | public static func lossless(_ type: B.Type) -> URLPartialIso { 277 | return losslessStringConvertible 278 | } 279 | } 280 | 281 | extension URLPartialIso where B: RawRepresentable & CaseIterable, B.RawValue == A { 282 | public static func raw(_ type: B.Type) -> URLPartialIso { 283 | rawRepresentable 284 | } 285 | } 286 | 287 | public extension URLFormat { 288 | /// Matches url that has no additional path componets that were not parsed yet. 289 | var end: URLFormat { 290 | return Self(self.parser <% Parser( 291 | parse: { $0.isEmpty ? ($0, unit) : nil }, 292 | print: const(URLRequestComponents()), 293 | template: const(URLRequestComponents()), 294 | templateValue: { unit } 295 | )) 296 | } 297 | } 298 | 299 | public func some() -> Parser { 300 | return Parser( 301 | parse: { format in 302 | format.isEmpty 303 | ? (format, "") 304 | : (format.with { $0.pathComponents = [] }, format.pathComponents.joined(separator: "/")) 305 | }, 306 | print: { str in URLRequestComponents().with { $0.urlComponents.path = str } }, 307 | template: { _ in URLRequestComponents(urlComponents: URLComponents(string: "*")!) }, 308 | templateValue: { "" } 309 | ) 310 | } 311 | 312 | public func httpMethod(_ method: String) -> Parser { 313 | return Parser( 314 | parse: { request in 315 | guard request.method == method else { return nil } 316 | return (request, unit) 317 | }, 318 | print: const(URLRequestComponents(method: method)), 319 | template: const(URLRequestComponents(method: method)), 320 | templateValue: { unit } 321 | ) 322 | } 323 | 324 | public func path(_ str: String) -> Parser { 325 | return Parser( 326 | parse: { format in 327 | return head(format.pathComponents).flatMap { (p, ps) in 328 | return p == str 329 | ? (format.with { $0.pathComponents = ps }, unit) 330 | : nil 331 | } 332 | }, 333 | print: { _ in URLRequestComponents().with { $0.urlComponents.path = str } }, 334 | template: { _ in URLRequestComponents().with { $0.urlComponents.path = str } }, 335 | templateValue: { unit } 336 | ) 337 | } 338 | 339 | public func path(_ f: URLPartialIso) -> Parser { 340 | return Parser( 341 | parse: { format in 342 | guard let (p, ps) = head(format.pathComponents), let v = try f.iso.apply(p) else { return nil } 343 | return (format.with { $0.pathComponents = ps }, v) 344 | }, 345 | print: { a in 346 | try f.iso.unapply(a).flatMap { s in 347 | URLRequestComponents().with { $0.urlComponents.path = s } 348 | } 349 | }, 350 | template: { a in 351 | try f.iso.unapply(a).flatMap { s in 352 | return URLRequestComponents().with { $0.urlComponents.path = ":" + "\(type(of: a))" } 353 | } 354 | }, 355 | templateValue: { f.templateValue } 356 | ) 357 | } 358 | 359 | public func query(_ key: String, _ f: URLPartialIso) -> Parser { 360 | return Parser( 361 | parse: { format in 362 | guard 363 | let queryItems = format.urlComponents.queryItems, 364 | let p = queryItems.first(where: { $0.name == key })?.value, 365 | let v = try f.iso.apply(p) 366 | else { return nil } 367 | return (format, v) 368 | }, 369 | print: { a in 370 | try f.iso.unapply(a).flatMap { s in 371 | URLRequestComponents().with { $0.urlComponents.queryItems = [URLQueryItem(name: key, value: s)] } 372 | } 373 | }, 374 | template: { a in 375 | try f.iso.unapply(a).flatMap { s in 376 | URLRequestComponents().with { $0.urlComponents.queryItems = [URLQueryItem(name: key, value: ":" + "\(type(of: a))")] } 377 | } 378 | }, 379 | templateValue: { f.templateValue } 380 | ) 381 | } 382 | 383 | private func head(_ xs: [A]) -> (A, [A])? { 384 | guard let x = xs.first else { return nil } 385 | return (x, Array(xs.dropFirst())) 386 | } 387 | 388 | // Because of prefix/postifx operators precedence values are accomulated not in a symmectric tuples so we have to fixup flattening 389 | 390 | public func flatten(_ f: ((A, B), C)) -> (A, B, C) { 391 | return (f.0.0, f.0.1, f.1) 392 | } 393 | 394 | public func parenthesize(_ a: A, _ b: B, _ c: C) -> ((A, B), C) { 395 | return ((a, b), c) 396 | } 397 | 398 | public func flatten(_ f: (((A, B), C), D)) -> (A, B, C, D) { 399 | return (f.0.0.0, f.0.0.1, f.0.1, f.1) 400 | } 401 | 402 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D) -> (((A, B), C), D) { 403 | return (((a, b), c), d) 404 | } 405 | 406 | public func flatten(_ f: ((((A, B), C), D), E)) -> (A, B, C, D, E) { 407 | return (f.0.0.0.0, f.0.0.0.1, f.0.0.1, f.0.1, f.1) 408 | } 409 | 410 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E) -> ((((A, B), C), D), E) { 411 | return ((((a, b), c), d), e) 412 | } 413 | 414 | public func flatten(_ f: (((((A, B), C), D), E), F)) -> (A, B, C, D, E, F) { 415 | return (f.0.0.0.0.0, f.0.0.0.0.1, f.0.0.0.1, f.0.0.1, f.0.1, f.1) 416 | } 417 | 418 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F) -> (((((A, B), C), D), E), F) { 419 | return (((((a, b), c), d), e), f) 420 | } 421 | 422 | public func flatten(_ f: ((((((A, B), C), D), E), F), G)) -> (A, B, C, D, E, F, G) { 423 | return (f.0.0.0.0.0.0, f.0.0.0.0.0.1, f.0.0.0.0.1, f.0.0.0.1, f.0.0.1, f.0.1, f.1) 424 | } 425 | 426 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G) -> ((((((A, B), C), D), E), F), G) { 427 | return ((((((a, b), c), d), e), f), g) 428 | } 429 | 430 | public func flatten(_ f: (((((((A, B), C), D), E), F), G), H)) -> (A, B, C, D, E, F, G, H) { 431 | return (f.0.0.0.0.0.0.0, f.0.0.0.0.0.0.1, f.0.0.0.0.0.1, f.0.0.0.0.1, f.0.0.0.1, f.0.0.1, f.0.1, f.1) 432 | } 433 | 434 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G, _ h: H) -> (((((((A, B), C), D), E), F), G), H) { 435 | return (((((((a, b), c), d), e), f), g), h) 436 | } 437 | 438 | public func flatten(_ f: ((((((((A, B), C), D), E), F), G), H), I)) -> (A, B, C, D, E, F, G, H, I) { 439 | return (f.0.0.0.0.0.0.0.0, f.0.0.0.0.0.0.0.1, f.0.0.0.0.0.0.1, f.0.0.0.0.0.1, f.0.0.0.0.1, f.0.0.0.1, f.0.0.1, f.0.1, f.1) 440 | } 441 | 442 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G, _ h: H, _ i: I) -> ((((((((A, B), C), D), E), F), G), H), I) { 443 | return ((((((((a, b), c), d), e), f), g), h), i) 444 | } 445 | 446 | public func flatten(_ f: (((((((((A, B), C), D), E), F), G), H), I), J)) -> (A, B, C, D, E, F, G, H, I, J) { 447 | return (f.0.0.0.0.0.0.0.0.0, f.0.0.0.0.0.0.0.0.1, f.0.0.0.0.0.0.0.1, f.0.0.0.0.0.0.1, f.0.0.0.0.0.1, f.0.0.0.0.1, f.0.0.0.1, f.0.0.1, f.0.1, f.1) 448 | } 449 | 450 | public func parenthesize(_ a: A, _ b: B, _ c: C, _ d: D, _ e: E, _ f: F, _ g: G, _ h: H, _ i: I, _ j: J) -> (((((((((A, B), C), D), E), F), G), H), I), J) { 451 | return (((((((((a, b), c), d), e), f), g), h), i), j) 452 | } 453 | 454 | public struct URLPartialIso { 455 | let iso: PartialIso 456 | let templateValue: B 457 | 458 | public init(iso: PartialIso, templateValue: B) { 459 | self.iso = iso 460 | self.templateValue = templateValue 461 | } 462 | 463 | public static func >>> (lhs: URLPartialIso, rhs: URLPartialIso) -> URLPartialIso { 464 | URLPartialIso(iso: lhs.iso >>> rhs.iso, templateValue: rhs.templateValue) 465 | } 466 | } 467 | 468 | extension URLPartialIso where B: CustomParserTemplateValueConvertible { 469 | public init(iso: PartialIso) { 470 | self.iso = iso 471 | self.templateValue = B.parserTemplateValue 472 | } 473 | } 474 | 475 | extension URLPartialIso where A == String, B == Any { 476 | public static var any: URLPartialIso { 477 | return .init(iso: .any, templateValue: "") 478 | } 479 | } 480 | 481 | extension URLPartialIso where A == String, B == Int { 482 | /// An isomorphism between strings and integers. 483 | public static var int: URLPartialIso { 484 | return .init(iso: .int, templateValue: 0) 485 | } 486 | } 487 | 488 | extension URLPartialIso where A == String, B == Bool { 489 | /// An isomorphism between strings and booleans. 490 | public static var bool: URLPartialIso { 491 | return .init(iso: .bool, templateValue: true) 492 | } 493 | } 494 | 495 | extension URLPartialIso where A == String, B == String { 496 | /// The identity isomorphism between strings. 497 | public static var string: URLPartialIso { 498 | return .init(iso: .id, templateValue: "") 499 | } 500 | } 501 | 502 | extension URLPartialIso where A == String, B == Character { 503 | /// The identity isomorphism between strings. 504 | public static var char: URLPartialIso { 505 | return .init(iso: .char, templateValue: Character(" ")) 506 | } 507 | } 508 | 509 | extension URLPartialIso where A == String, B == Double { 510 | /// An isomorphism between strings and doubles. 511 | public static var double: URLPartialIso { 512 | return .init(iso: .double, templateValue: 0.0) 513 | } 514 | } 515 | 516 | extension URLPartialIso where A == String, B: LosslessStringConvertible & CustomParserTemplateValueConvertible { 517 | public static var losslessStringConvertible: URLPartialIso { 518 | return .init(iso: .losslessStringConvertible) 519 | } 520 | } 521 | 522 | extension URLPartialIso where B: RawRepresentable & CaseIterable, B.RawValue == A { 523 | public static var rawRepresentable: URLPartialIso { 524 | precondition(B.allCases.first != nil) 525 | return .init(iso: .rawRepresentable, templateValue: B.allCases.first!) 526 | } 527 | } 528 | 529 | extension URLPartialIso where A == String, B == UUID { 530 | public static var uuid: URLPartialIso { 531 | return .init(iso: PartialIso( 532 | apply: UUID.init(uuidString:), 533 | unapply: { $0.uuidString } 534 | ), templateValue: UUID()) 535 | } 536 | } 537 | 538 | --------------------------------------------------------------------------------