├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md └── workflows │ ├── ci.yml │ ├── format.yml │ └── release.yml ├── .gitignore ├── .spi.yml ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── URLRouting │ ├── Body.swift │ ├── Builders │ │ └── Variadics.swift │ ├── Client │ │ └── Client.swift │ ├── Cookies.swift │ ├── Documentation.docc │ │ ├── Articles │ │ │ └── GettingStarted.md │ │ └── URLRouting.md │ ├── Exports.swift │ ├── Field.swift │ ├── FormData.swift │ ├── Fragment.swift │ ├── Headers.swift │ ├── Host.swift │ ├── Internal │ │ ├── AnyEquatable.swift │ │ ├── Breakpoint.swift │ │ └── Deprecations.swift │ ├── Method.swift │ ├── Parsing │ │ ├── Parse.swift │ │ └── ParserPrinter.swift │ ├── Path.swift │ ├── PathBuilder.swift │ ├── Printing.swift │ ├── Query.swift │ ├── Route.swift │ ├── Router.swift │ ├── RoutingError.swift │ ├── Scheme.swift │ ├── URLRequestData+Foundation.swift │ └── URLRequestData.swift ├── swift-url-routing-benchmark │ ├── Common │ │ └── Benchmarking.swift │ ├── Routing.swift │ └── main.swift └── variadics-generator │ ├── VariadicsGenerator.swift │ └── main.swift └── Tests └── URLRoutingTests ├── RoutingErrorTests.swift ├── URLRoutingClientTests.swift └── URLRoutingTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | macos_tests: 14 | runs-on: macos-14 15 | strategy: 16 | matrix: 17 | xcode: 18 | - "15.4" 19 | command: 20 | - test 21 | - benchmarks 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Select Xcode ${{ matrix.xcode }} 25 | run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 26 | - name: System 27 | run: system_profiler SPHardwareDataType 28 | - name: Run ${{ matrix.command }} 29 | run: make ${{ matrix.command }} 30 | 31 | ubuntu_tests: 32 | strategy: 33 | matrix: 34 | os: [ubuntu-20.04] 35 | 36 | runs-on: ${{ matrix.os }} 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Build 41 | run: swift build 42 | - name: Run tests 43 | run: swift test 44 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | swift_format: 10 | name: swift-format 11 | runs-on: macOS-14 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Xcode Select 15 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 16 | - name: Install 17 | run: brew install swift-format 18 | - name: Format 19 | run: make format 20 | - uses: stefanzweifel/git-auto-commit-action@v4 21 | with: 22 | commit_message: Run swift-format 23 | branch: 'main' 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | project-channel: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Dump Github context 11 | env: 12 | GITHUB_CONTEXT: ${{ toJSON(github) }} 13 | run: echo "$GITHUB_CONTEXT" 14 | - name: Slack Notification on SUCCESS 15 | if: success() 16 | uses: tokorom/action-slack-incoming-webhook@main 17 | env: 18 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} 19 | with: 20 | text: swift-url-routing ${{ github.event.release.tag_name }} has been released. 21 | blocks: | 22 | [ 23 | { 24 | "type": "header", 25 | "text": { 26 | "type": "plain_text", 27 | "text": "swift-url-routing ${{ github.event.release.tag_name}}" 28 | } 29 | }, 30 | { 31 | "type": "section", 32 | "text": { 33 | "type": "mrkdwn", 34 | "text": ${{ toJSON(github.event.release.body) }} 35 | } 36 | }, 37 | { 38 | "type": "section", 39 | "text": { 40 | "type": "mrkdwn", 41 | "text": "${{ github.event.release.html_url }}" 42 | } 43 | } 44 | ] 45 | 46 | releases-channel: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Dump Github context 50 | env: 51 | GITHUB_CONTEXT: ${{ toJSON(github) }} 52 | run: echo "$GITHUB_CONTEXT" 53 | - name: Slack Notification on SUCCESS 54 | if: success() 55 | uses: tokorom/action-slack-incoming-webhook@main 56 | env: 57 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} 58 | with: 59 | text: swift-url-routing ${{ github.event.release.tag_name }} has been released. 60 | blocks: | 61 | [ 62 | { 63 | "type": "header", 64 | "text": { 65 | "type": "plain_text", 66 | "text": "swift-url-routing ${{ github.event.release.tag_name}}" 67 | } 68 | }, 69 | { 70 | "type": "section", 71 | "text": { 72 | "type": "mrkdwn", 73 | "text": ${{ toJSON(github.event.release.body) }} 74 | } 75 | }, 76 | { 77 | "type": "section", 78 | "text": { 79 | "type": "mrkdwn", 80 | "text": "${{ github.event.release.html_url }}" 81 | } 82 | } 83 | ] 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | scheme: URLRouting 6 | - platform: macos-xcodebuild 7 | scheme: URLRouting 8 | - platform: tvos 9 | scheme: URLRouting 10 | - platform: watchos 11 | scheme: URLRouting 12 | - documentation_targets: [URLRouting] 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Point-Free 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: test 2 | 3 | benchmarks: 4 | swift run -c release swift-url-routing-benchmark 5 | 6 | test: 7 | swift test \ 8 | --enable-test-discovery \ 9 | --parallel 10 | 11 | test-linux: 12 | docker run \ 13 | --rm \ 14 | -v "$(PWD):$(PWD)" \ 15 | -w "$(PWD)" \ 16 | swift:5.7 \ 17 | bash -c 'make test' 18 | 19 | format: 20 | swift format --in-place --recursive \ 21 | ./Package.swift ./Sources ./Tests 22 | find . -type f -name '*.md' -print0 | xargs -0 perl -pi -e 's/ +$$//' 23 | 24 | generate-variadics: 25 | swift run variadics-generator > Sources/URLRouting/Builders/Variadics.swift 26 | 27 | .PHONY: benchmarks format generate-variadics test 28 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser", 7 | "state" : { 8 | "revision" : "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 9 | "version" : "0.5.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-benchmark", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/google/swift-benchmark", 16 | "state" : { 17 | "revision" : "a0564bf88df5f94eec81348a2f089494c6b28d80", 18 | "version" : "0.1.1" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-case-paths", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-case-paths", 25 | "state" : { 26 | "revision" : "71344dd930fde41e8f3adafe260adcbb2fc2a3dc", 27 | "version" : "1.5.4" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-collections", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-collections", 34 | "state" : { 35 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 36 | "version" : "1.1.2" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-docc-plugin", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-docc-plugin", 43 | "state" : { 44 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 45 | "version" : "1.3.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-docc-symbolkit", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-docc-symbolkit", 52 | "state" : { 53 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 54 | "version" : "1.0.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-parsing", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-parsing", 61 | "state" : { 62 | "revision" : "a0e7d73f462c1c38c59dc40a3969ac40cea42950", 63 | "version" : "0.13.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-syntax", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/swiftlang/swift-syntax", 70 | "state" : { 71 | "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", 72 | "version" : "600.0.0-prerelease-2024-06-12" 73 | } 74 | }, 75 | { 76 | "identity" : "xctest-dynamic-overlay", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 79 | "state" : { 80 | "revision" : "357ca1e5dd31f613a1d43320870ebc219386a495", 81 | "version" : "1.2.2" 82 | } 83 | } 84 | ], 85 | "version" : 2 86 | } 87 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-url-routing", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | ], 13 | products: [ 14 | .library(name: "URLRouting", targets: ["URLRouting"]) 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/apple/swift-argument-parser", from: "0.5.0"), 18 | .package(url: "https://github.com/apple/swift-collections", from: "1.0.3"), 19 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), 20 | .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.13.0"), 21 | .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), 22 | .package(url: "https://github.com/google/swift-benchmark", from: "0.1.1"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "URLRouting", 27 | dependencies: [ 28 | .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), 29 | .product(name: "OrderedCollections", package: "swift-collections"), 30 | .product(name: "Parsing", package: "swift-parsing"), 31 | ] 32 | ), 33 | .testTarget( 34 | name: "URLRoutingTests", 35 | dependencies: [ 36 | "URLRouting" 37 | ] 38 | ), 39 | .executableTarget( 40 | name: "swift-url-routing-benchmark", 41 | dependencies: [ 42 | "URLRouting", 43 | .product(name: "Benchmark", package: "swift-benchmark"), 44 | ] 45 | ), 46 | .executableTarget( 47 | name: "variadics-generator", 48 | dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")] 49 | ), 50 | ] 51 | ) 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URL Routing 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-url-routing%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-url-routing) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-url-routing%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-url-routing) 5 | 6 | A bidirectional URL router with more type safety and less fuss. This library is built with [Parsing][swift-parsing]. 7 | 8 | --- 9 | 10 | * [Motivation](#Motivation) 11 | * [Getting started](#Getting-started) 12 | * [Documentation](#Documentation) 13 | * [License](#License) 14 | 15 | ## Learn More 16 | 17 | This library was discussed in an [episode](http://pointfree.co/episodes/ep187-tour-of-parser-printers-url-routing) of [Point-Free](http://pointfree.co), a video series exploring functional programming and the Swift programming and the Swift language, hosted by [Brandon Williams](https://twitter.com/mbrandonw) and [Stephen Celis](https://twitter.com/stephencelis). 18 | 19 | 20 | video poster image 21 | 22 | 23 | ## Motivation 24 | 25 | URL routing is a ubiquitous problem in both client-side and server-side applications: 26 | 27 | * Clients, such as iOS applications, need to route URLs for deep-linking, which amounts to picking apart a URL in order to figure out where to navigate the user in the app. 28 | * Servers, such as [Vapor][vapor] applications, also need to pick apart URL requests to figure out what page to serve, but also need to _generate_ valid URLs for linking within the website. 29 | 30 | This library provides URL routing function for both client and server applications, and does so in a composable, type-safe manner. 31 | 32 | ## Getting Started 33 | 34 | To use the library you first begin with a domain modeling exercise. You model a route enum that represents each URL you want to recognize in your application, and each case of the enum holds the data you want to extract from the URL. 35 | 36 | For example, if we had screens in our Books application that represent showing all books, showing a particular book, and searching books, we can model this as an enum: 37 | 38 | ```swift 39 | enum AppRoute { 40 | case books 41 | case book(id: Int) 42 | case searchBooks(query: String, count: Int = 10) 43 | } 44 | ``` 45 | 46 | Notice that we only encode the data we want to extract from the URL in these cases. There are no details of where this data lives in the URL, such as whether it comes from path parameters, query parameters or POST body data. 47 | 48 | Those details are determined by the router, which can be constructed with the tools shipped in this library. Its purpose is to transform an incoming URL into the `AppRoute` type. For example: 49 | 50 | ```swift 51 | import URLRouting 52 | 53 | let appRouter = OneOf { 54 | // GET /books 55 | Route(.case(AppRoute.books)) { 56 | Path { "books" } 57 | } 58 | 59 | // GET /books/:id 60 | Route(.case(AppRoute.book(id:))) { 61 | Path { "books"; Digits() } 62 | } 63 | 64 | // GET /books/search?query=:query&count=:count 65 | Route(.case(AppRoute.searchBooks(query:count:))) { 66 | Path { "books"; "search" } 67 | Query { 68 | Field("query") 69 | Field("count", default: 10) { Digits() } 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | This router describes at a high-level how to pick apart the path components, query parameters, and more from a URL in order to transform it into an `AppRoute`. 76 | 77 | Once this router is defined you can use it to implement deep-linking logic in your application. You can implement a single function that accepts a `URL`, use the router's `match` method to transform it into an `AppRoute`, and then switch on the route to handle each deep link destination: 78 | 79 | ```swift 80 | func handleDeepLink(url: URL) throws { 81 | switch try appRouter.match(url: url) { 82 | case .books: 83 | // navigate to books screen 84 | 85 | case let .book(id: id): 86 | // navigate to book with id 87 | 88 | case let .searchBooks(query: query, count: count): 89 | // navigate to search screen with query and count 90 | } 91 | } 92 | ``` 93 | 94 | This kind of routing is incredibly useful in client side iOS applications, but it can also be used in server-side applications. Even better, it can automatically transform `AppRoute` values back into URL's which is handy for linking to various parts of your website: 95 | 96 | ```swift 97 | appRouter.path(for: .searchBooks(query: "Blob Bio")) 98 | // "/books/search?query=Blob%20Bio" 99 | ``` 100 | 101 | ```swift 102 | ul { 103 | for book in books { 104 | li { 105 | a { 106 | book.title 107 | } 108 | .href(appRouter.path(for: .book(id: book.id))) 109 | } 110 | } 111 | } 112 | ``` 113 | ```html 114 | 119 | ``` 120 | 121 | For [Vapor][vapor] bindings to URL Routing, see the [Vapor Routing][vapor-routing] package. 122 | 123 | ## Documentation 124 | 125 | The documentation for releases and main are available [here](https://swiftpackageindex.com/pointfreeco/swift-url-routing/main/documentation/urlrouting). 126 | 127 | ## License 128 | 129 | This library is released under the MIT license. See [LICENSE](LICENSE) for details. 130 | 131 | [swift-parsing]: http://github.com/pointfreeco/swift-parsing 132 | [vapor-routing]: http://github.com/pointfreeco/vapor-routing 133 | [vapor]: http://vapor.codes 134 | -------------------------------------------------------------------------------- /Sources/URLRouting/Body.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Parses a request's body using a byte parser. 4 | public struct Body: Parser where Bytes.Input == Data { 5 | @usableFromInline 6 | let bytesParser: Bytes 7 | 8 | @inlinable 9 | public init(@ParserBuilder _ bytesParser: () -> Bytes) { 10 | self.bytesParser = bytesParser() 11 | } 12 | 13 | /// Initializes a body parser from a byte conversion. 14 | /// 15 | /// Useful for parsing a request body in its entirety, for example as a JSON payload. 16 | /// 17 | /// ```swift 18 | /// struct Comment: Codable { 19 | /// var author: String 20 | /// var message: String 21 | /// } 22 | /// 23 | /// Body(.json(Comment.self)) 24 | /// ``` 25 | /// 26 | /// - Parameter bytesConversion: A conversion that transforms bytes into some other type. 27 | @inlinable 28 | public init(_ bytesConversion: C) 29 | where Bytes == Parsers.MapConversion>, C> { 30 | self.bytesParser = Rest().replaceError(with: .init()).map(bytesConversion) 31 | } 32 | 33 | /// Initializes a body parser that parses the body as data in its entirety. 34 | @inlinable 35 | public init() where Bytes == Parsers.ReplaceError> { 36 | self.bytesParser = Rest().replaceError(with: .init()) 37 | } 38 | 39 | @inlinable 40 | public func parse(_ input: inout URLRequestData) throws -> Bytes.Output { 41 | guard var body = input.body 42 | else { throw RoutingError() } 43 | 44 | let output = try self.bytesParser.parse(&body) 45 | input.body = body 46 | 47 | return output 48 | } 49 | } 50 | 51 | extension Body: ParserPrinter where Bytes: ParserPrinter { 52 | @inlinable 53 | public func print(_ output: Bytes.Output, into input: inout URLRequestData) rethrows { 54 | input.body = try self.bytesParser.print(output) 55 | } 56 | } 57 | 58 | extension Parser where Input == URLRequestData { 59 | public typealias Body = URLRouting.Body 60 | } 61 | -------------------------------------------------------------------------------- /Sources/URLRouting/Client/Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IssueReporting 3 | import Parsing 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | /// A type that can make requests to a server, download the response, and decode the response into a 10 | /// model. 11 | /// 12 | /// You do not typically construct this type directly from its initializer, and instead use the 13 | /// ``live(router:session:)`` static method for creating an API client from a parser-printer, or use 14 | /// the ``failing`` static variable for creating an API client that throws an error when a request 15 | /// is made and then use ``override(_:with:)-1ot4o`` to override certain routes with mocked 16 | /// responses. 17 | public struct URLRoutingClient { 18 | var request: (Route) async throws -> (Data, URLResponse) 19 | let decoder: JSONDecoder 20 | 21 | public init( 22 | request: @escaping (Route) async throws -> (Data, URLResponse), 23 | decoder: JSONDecoder = .init() 24 | ) { 25 | self.request = request 26 | self.decoder = decoder 27 | } 28 | 29 | /// Makes a request to a route. 30 | /// 31 | /// - Parameter route: The route to request. 32 | /// - Returns: The data and response. 33 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 34 | public func data(for route: Route) async throws -> (value: Data, response: URLResponse) { 35 | try await self.request(route) 36 | } 37 | 38 | /// Makes a request to a route. 39 | /// 40 | /// - Parameters: 41 | /// - route: The route to request. 42 | /// - type: The type of value to decode the response into. 43 | /// - decoder: A JSON decoder. 44 | /// - Returns: The decoded value. 45 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 46 | public func decodedResponse( 47 | for route: Route, 48 | as type: Value.Type = Value.self, 49 | decoder: JSONDecoder? = nil 50 | ) async throws -> (value: Value, response: URLResponse) { 51 | let (data, response) = try await self.data(for: route) 52 | do { 53 | return (try (decoder ?? self.decoder).decode(type, from: data), response) 54 | } catch { 55 | throw URLRoutingDecodingError(bytes: data, response: response, underlyingError: error) 56 | } 57 | } 58 | } 59 | 60 | public struct URLRoutingDecodingError: Error { 61 | public let bytes: Data 62 | public let response: URLResponse 63 | public let underlyingError: Error 64 | } 65 | 66 | extension URLRoutingClient { 67 | /// Constructs a "live" API client that makes a request to a server using a `URLSession`. 68 | /// 69 | /// This client makes live requests by using the router to turn routes into URL requests, 70 | /// and then using `URLSession` to make the request. 71 | /// 72 | /// - Parameters: 73 | /// - router: A router. 74 | /// - session: A URL session. 75 | /// - Returns: A live API client that makes requests through a URL session. 76 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 77 | public static func live( 78 | router: R, 79 | session: URLSession = .shared, 80 | decoder: JSONDecoder = .init() 81 | ) -> Self 82 | where R.Input == URLRequestData, R.Output == Route { 83 | Self.init( 84 | request: { route in 85 | let request = try router.request(for: route) 86 | 87 | #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) 88 | if #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) { 89 | return try await session.data(for: request) 90 | } 91 | #endif 92 | var dataTask: URLSessionDataTask? 93 | let cancel: () -> Void = { dataTask?.cancel() } 94 | 95 | return try await withTaskCancellationHandler( 96 | operation: { 97 | try await withCheckedThrowingContinuation { continuation in 98 | dataTask = session.dataTask(with: request) { data, response, error in 99 | guard 100 | let data = data, 101 | let response = response 102 | else { 103 | continuation.resume(throwing: error ?? URLError(.badServerResponse)) 104 | return 105 | } 106 | 107 | continuation.resume(returning: (data, response)) 108 | } 109 | dataTask?.resume() 110 | } 111 | }, 112 | onCancel: { cancel() } 113 | ) 114 | }, 115 | decoder: decoder 116 | ) 117 | } 118 | } 119 | 120 | extension URLRoutingClient { 121 | /// An ``APIClient`` that immediately throws an error when a request is made. 122 | /// 123 | /// This client is useful when testing a feature that uses only a small subset of the available 124 | /// routes in the API client. You can creating a failing API client, and then 125 | /// ``override(_:with:)-1ot4o`` certain routes that return mocked data. 126 | public static var failing: Self { 127 | Self { 128 | let message = """ 129 | Failed to respond to route: \(debugPrint($0)) 130 | 131 | Use '\(Self.self).override' to supply a default response for this route. 132 | """ 133 | reportIssue(message) 134 | throw UnimplementedEndpoint(message: message) 135 | } 136 | } 137 | 138 | /// Constructs a new ``URLRoutingClient`` that returns a certain response for a specified route, 139 | /// and all other routes are passed through to the receiver. 140 | /// 141 | /// - Parameters: 142 | /// - route: The route you want to override. 143 | /// - response: The response to return for the route. 144 | /// - Returns: A new ``URLRoutingClient``. 145 | public func override( 146 | _ route: Route, 147 | with response: @escaping () throws -> Result<(data: Data, response: URLResponse), URLError> 148 | ) -> Self where Route: Equatable { 149 | self.override({ $0 == route }, with: response) 150 | } 151 | 152 | /// Constructs a new ``URLRoutingClient`` that returns a certain response for specific routes, and 153 | /// all other routes are passed through to the receiver. 154 | /// 155 | /// - Parameters: 156 | /// - extract: A closure that determines which routes should be overridden. 157 | /// - response: A closure that determines the response for when a route is overridden. 158 | /// - Returns: A new ``URLRoutingClient``. 159 | public func override( 160 | _ extract: @escaping (Route) -> Value?, 161 | with response: @escaping (Value) throws -> Result<(data: Data, response: URLResponse), URLError> 162 | ) -> Self { 163 | var copy = self 164 | copy.request = { [self] route in 165 | if let value = extract(route) { 166 | return try response(value).get() 167 | } else { 168 | return try await self.request(route) 169 | } 170 | } 171 | return copy 172 | } 173 | 174 | /// Constructs a new ``URLRoutingClient`` that returns a certain response for specific routes, and 175 | /// all other routes are passed through to the receiver. 176 | /// 177 | /// - Parameters: 178 | /// - predicate: A closure that determines if a route matches. 179 | /// - response: A closure that determines the response for when a route matches. 180 | /// - Returns: A new ``URLRoutingClient``. 181 | public func override( 182 | _ predicate: @escaping (Route) -> Bool, 183 | with response: @escaping () throws -> Result<(data: Data, response: URLResponse), URLError> 184 | ) -> Self { 185 | var copy = self 186 | copy.request = { [self] route in 187 | if predicate(route) { 188 | return try response().get() 189 | } else { 190 | return try await self.request(route) 191 | } 192 | } 193 | return copy 194 | } 195 | } 196 | 197 | extension Result where Success == (data: Data, response: URLResponse), Failure == URLError { 198 | /// Constructs a `Result` that represents a HTTP status 200 response. 199 | /// 200 | /// This method is most useful when used in conjunction with 201 | /// ``URLRoutingClient/override(_:with:)-4j1y4`` where you start with a 202 | /// ``URLRoutingClient/failing`` API client and then override certain routes to return mocked 203 | /// responses: 204 | /// 205 | /// ```swift 206 | /// let apiClient = URLRoutingClient.failing 207 | /// .override(SiteRoute.search, with: { .ok(SearchResponse()) }) 208 | /// ``` 209 | /// 210 | /// - Parameters: 211 | /// - value: The value to encode into data for the response. 212 | /// - headerFields: Optional header fields to add to the response. 213 | /// - encoder: The `JSONEncoder` to use to encode the value. 214 | /// - Returns: A result. 215 | public static func ok( 216 | _ value: T, 217 | headerFields: [String: String]? = nil, 218 | encoder: JSONEncoder = .init() 219 | ) throws -> Self { 220 | .success( 221 | ( 222 | try encoder.encode(value), 223 | HTTPURLResponse( 224 | url: .init(string: "/")!, 225 | statusCode: 200, 226 | httpVersion: nil, 227 | headerFields: headerFields 228 | )! 229 | ) 230 | ) 231 | } 232 | } 233 | 234 | private struct UnimplementedEndpoint: LocalizedError { 235 | let message: String 236 | 237 | var errorDescription: String? { 238 | self.message 239 | } 240 | } 241 | 242 | private func debugPrint(_ value: Any) -> String { 243 | func debugTypeHelp(_ type: Any.Type) -> String { 244 | var name = String(reflecting: type) 245 | if let index = name.firstIndex(of: ".") { 246 | name.removeSubrange(...index) 247 | } 248 | return 249 | name 250 | .replacingOccurrences( 251 | of: #"<.+>|\(unknown context at \$[[:xdigit:]]+\)\."#, 252 | with: "", 253 | options: .regularExpression 254 | ) 255 | } 256 | 257 | func debugTupleHelp(_ children: Mirror.Children) -> String { 258 | children.map { label, value in 259 | let childOutput = debugHelp(value) 260 | let label = 261 | label 262 | .map { $0.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil ? "" : "\($0): " } 263 | ?? "" 264 | return "\(label)\(childOutput)" 265 | } 266 | .joined(separator: ", ") 267 | } 268 | 269 | func debugHelp(_ value: Any) -> String { 270 | let mirror = Mirror(reflecting: value) 271 | switch (value, mirror.displayStyle) { 272 | case (_, .enum): 273 | guard let child = mirror.children.first else { 274 | let childOutput = "\(value)" 275 | return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" 276 | } 277 | let childOutput = debugHelp(child.value) 278 | return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" 279 | case (_, .tuple): 280 | return debugTupleHelp(mirror.children) 281 | case (_, .struct): 282 | return "\(debugTypeHelp(mirror.subjectType))(\(debugTupleHelp(mirror.children)))" 283 | case let (value as CustomDebugStringConvertible, _): 284 | return value.debugDescription 285 | case let (value as CustomStringConvertible, _): 286 | return value.description 287 | default: 288 | return "_" 289 | } 290 | } 291 | 292 | return (value as? CustomDebugStringConvertible)?.debugDescription 293 | ?? "\(debugTypeHelp(type(of: value)))\(debugHelp(value))" 294 | } 295 | -------------------------------------------------------------------------------- /Sources/URLRouting/Cookies.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OrderedCollections 3 | 4 | /// Parses a request's cookies using field parsers. 5 | public struct Cookies: Parser where Parsers.Input == URLRequestData.Fields { 6 | @usableFromInline 7 | let cookieParsers: Parsers 8 | 9 | @inlinable 10 | public init(@ParserBuilder build: () -> Parsers) { 11 | self.cookieParsers = build() 12 | } 13 | 14 | @inlinable 15 | public func parse(_ input: inout URLRequestData) throws -> Parsers.Output { 16 | guard let cookie = input.headers["cookie"] 17 | else { throw RoutingError() } 18 | 19 | var fields: Parsers.Input = cookie.reduce( 20 | into: .init([:], isNameCaseSensitive: true) 21 | ) { fields, field in 22 | guard let cookies = field?.components(separatedBy: "; ") 23 | else { return } 24 | 25 | for cookie in cookies { 26 | let pair = cookie.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) 27 | guard pair.count == 2 else { continue } 28 | fields[String(pair[0]), default: []].append(pair[1]) 29 | } 30 | } 31 | 32 | return try self.cookieParsers.parse(&fields) 33 | } 34 | } 35 | 36 | extension Cookies: ParserPrinter where Parsers: ParserPrinter { 37 | @inlinable 38 | public func print(_ output: Parsers.Output, into input: inout URLRequestData) rethrows { 39 | var cookies = URLRequestData.Fields() 40 | try self.cookieParsers.print(output, into: &cookies) 41 | 42 | input.headers["cookie", default: []].prepend( 43 | cookies 44 | .flatMap { name, values in values.map { "\(name)=\($0 ?? "")" } } 45 | .joined(separator: "; ")[...] 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/URLRouting/Documentation.docc/Articles/GettingStarted.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Learn how to integrate URL Routing into your project and write your first router. 4 | 5 | ## Adding URL Routing as a dependency 6 | 7 | To use the URL Routing library in a SwiftPM project, add it to the dependencies of your Package.swift and specify the `URLRouting` product in any targets that need access to the library: 8 | 9 | ```swift 10 | let package = Package( 11 | dependencies: [ 12 | .package(url: "https://github.com/pointfreeco/swift-url-routing", from: "0.1.0"), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "", 17 | dependencies: [.product(name: "URLRouting", package: "swift-url-routing")] 18 | ) 19 | ] 20 | ) 21 | ``` 22 | -------------------------------------------------------------------------------- /Sources/URLRouting/Documentation.docc/URLRouting.md: -------------------------------------------------------------------------------- 1 | # ``URLRouting`` 2 | 3 | A bidirectional URL router with more type safety and less fuss. This library is built with [Parsing][swift-parsing]. 4 | 5 | ## Additional Resources 6 | 7 | - [GitHub Repo](https://github.com/pointfreeco/swift-parsing) 8 | - [Discussions](https://github.com/pointfreeco/swift-parsing/discussions) 9 | - [Point-Free Videos](https://www.pointfree.co/collections/parsing) 10 | 11 | ## Overview 12 | 13 | URL routing is the process of turning a URL request into well-structured data so that you know where to navigate a user in your application. It is also useful to be able to do the opposite: turn the well-structured data back into a URL request. 14 | 15 | ## Topics 16 | 17 | ### Articles 18 | 19 | - ``GettingStarted`` 20 | 21 | ## See Also 22 | 23 | The collection of videos from [Point-Free](https://www.pointfree.co) that dive deep into the 24 | development of the Parsing library. 25 | 26 | * [Point-Free Videos](https://www.pointfree.co/collections/parsing) 27 | 28 | [swift-parsing]: http://github.com/pointfreeco/swift-parsing 29 | -------------------------------------------------------------------------------- /Sources/URLRouting/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import Parsing 2 | -------------------------------------------------------------------------------- /Sources/URLRouting/Field.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | /// Parses a named field's value with a string parser. 4 | /// 5 | /// Useful for incrementally parsing values from various request fields, including ``Query`` 6 | /// parameters, ``Headers`` and ``Cookies``, and ``FormData``. 7 | /// 8 | /// For example, a search endpoint may include a few query items, which can be specified as fields: 9 | /// 10 | /// ```swift 11 | /// Query { 12 | /// Field("q", .string, default: "") 13 | /// Field("page", default: 1) { 14 | /// Digits() 15 | /// } 16 | /// Field("per_page", default: 20) { 17 | /// Digits() 18 | /// } 19 | /// } 20 | /// ``` 21 | public struct Field: Parser where Value.Input == Substring { 22 | @usableFromInline 23 | let defaultValue: Value.Output? 24 | 25 | @usableFromInline 26 | let name: String 27 | 28 | @usableFromInline 29 | let valueParser: Value 30 | 31 | /// Initializes a named field parser. 32 | /// 33 | /// - Parameters: 34 | /// - name: The name of the field. 35 | /// - defaultValue: A default value if the field is absent. Prefer specifying a default over 36 | /// applying `Parser.replaceError(with:)` if parsing should fail for invalid values. 37 | /// - value: A parser that parses the field's substring value into something more 38 | /// well-structured. 39 | @inlinable 40 | public init( 41 | _ name: String, 42 | default defaultValue: Value.Output? = nil, 43 | @ParserBuilder _ value: () -> Value 44 | ) { 45 | self.defaultValue = defaultValue 46 | self.name = name 47 | self.valueParser = value() 48 | } 49 | 50 | /// Initializes a named field parser. 51 | /// 52 | /// - Parameters: 53 | /// - name: The name of the field. 54 | /// - value: A conversion that transforms the field's substring value into something more 55 | /// well-structured. 56 | /// - defaultValue: A default value if the field is absent. Prefer specifying a default over 57 | /// applying `Parser.replaceError(with:)` if parsing should fail for invalid values. 58 | @inlinable 59 | public init( 60 | _ name: String, 61 | _ value: C, 62 | default defaultValue: Value.Output? = nil 63 | ) where Value == Parsers.MapConversion>, C> { 64 | self.defaultValue = defaultValue 65 | self.name = name 66 | self.valueParser = Rest().replaceError(with: "").map(value) 67 | } 68 | 69 | @inlinable 70 | public init( 71 | _ name: String, 72 | default defaultValue: Value.Output? = nil 73 | ) 74 | where 75 | Value == Parsers.MapConversion< 76 | Parsers.ReplaceError>, Conversions.SubstringToString 77 | > 78 | { 79 | self.defaultValue = defaultValue 80 | self.name = name 81 | self.valueParser = Rest().replaceError(with: "").map(.string) 82 | } 83 | 84 | @inlinable 85 | public func parse(_ input: inout URLRequestData.Fields) throws -> Value.Output { 86 | guard 87 | let wrapped = input[self.name]?.first, 88 | var value = wrapped 89 | else { 90 | guard let defaultValue = self.defaultValue 91 | else { throw RoutingError() } 92 | return defaultValue 93 | } 94 | 95 | let output = try self.valueParser.parse(&value) 96 | input[self.name]?.removeFirst() 97 | if input[self.name]?.isEmpty ?? true { 98 | input[self.name] = nil 99 | } 100 | return output 101 | } 102 | } 103 | 104 | extension Field: ParserPrinter where Value: ParserPrinter { 105 | @inlinable 106 | public func print(_ output: Value.Output, into input: inout URLRequestData.Fields) rethrows { 107 | if let defaultValue = self.defaultValue, isEqual(output, defaultValue) { return } 108 | try input.fields.updateValue( 109 | forKey: input.isNameCaseSensitive ? self.name : self.name.lowercased(), 110 | insertingDefault: [], 111 | at: 0, 112 | with: { $0.prepend(try self.valueParser.print(output)) } 113 | ) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/URLRouting/FormData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OrderedCollections 3 | 4 | /// Parser form-encoded data using field parsers. 5 | public struct FormData: Parser 6 | where FieldParsers.Input == URLRequestData.Fields { 7 | @usableFromInline 8 | let fieldParsers: FieldParsers 9 | 10 | @inlinable 11 | public init(@ParserBuilder build: () -> FieldParsers) { 12 | self.fieldParsers = build() 13 | } 14 | 15 | @inlinable 16 | public func parse(_ input: inout Data) rethrows -> FieldParsers.Output { 17 | var fields: FieldParsers.Input = String(decoding: input, as: UTF8.self) 18 | .split(separator: "&") 19 | .reduce(into: .init([:], isNameCaseSensitive: true)) { fields, field in 20 | let pair = 21 | field 22 | .split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) 23 | .compactMap { $0.replacingOccurrences(of: "+", with: " ").removingPercentEncoding } 24 | let name = pair[0] 25 | let value = pair.count == 2 ? pair[1][...] : nil 26 | fields[name, default: []].append(value) 27 | } 28 | 29 | let output = try self.fieldParsers.parse(&fields) 30 | 31 | input = .init(encoding: fields) 32 | return output 33 | } 34 | } 35 | 36 | extension FormData: ParserPrinter where FieldParsers: ParserPrinter { 37 | @inlinable 38 | public func print(_ output: FieldParsers.Output, into input: inout Data) rethrows { 39 | var fields = URLRequestData.Fields() 40 | try self.fieldParsers.print(output, into: &fields) 41 | input = .init(encoding: fields) 42 | } 43 | } 44 | 45 | extension Data { 46 | @usableFromInline 47 | init(encoding fields: URLRequestData.Fields) { 48 | self.init( 49 | fields 50 | .flatMap { pair -> [String] in 51 | let (name, values) = pair 52 | guard let name = name.addingPercentEncoding(withAllowedCharacters: .urlQueryParamAllowed) 53 | else { return [] } 54 | 55 | return values.compactMap { value in 56 | guard let value = value 57 | else { return name } 58 | 59 | guard 60 | let value = value.addingPercentEncoding(withAllowedCharacters: .urlQueryParamAllowed) 61 | else { return nil } 62 | 63 | return "\(name)=\(value)" 64 | } 65 | } 66 | .joined(separator: "&") 67 | .utf8 68 | ) 69 | } 70 | } 71 | 72 | extension CharacterSet { 73 | @usableFromInline 74 | static let urlQueryParamAllowed = CharacterSet 75 | .urlQueryAllowed 76 | .subtracting(Self(charactersIn: ":#[]@!$&'()*+,;=")) 77 | } 78 | -------------------------------------------------------------------------------- /Sources/URLRouting/Fragment.swift: -------------------------------------------------------------------------------- 1 | import Parsing 2 | 3 | /// Parses a request's fragment subcomponent with a substring parser. 4 | public struct Fragment: Parser where ValueParser.Input == Substring { 5 | 6 | @usableFromInline 7 | let valueParser: ValueParser 8 | 9 | /// Initializes a fragment parser that parses the fragment as a string in its entirety. 10 | @inlinable 11 | public init() 12 | where 13 | ValueParser == Parsers.MapConversion< 14 | Parsers.ReplaceError>, Conversions.SubstringToString 15 | > 16 | { 17 | self.valueParser = Rest().replaceError(with: "").map(.string) 18 | } 19 | 20 | /// Initializes a fragment parser. 21 | /// 22 | /// - Parameter value: A parser that parses the fragment's substring value into something 23 | /// more well-structured. 24 | @inlinable 25 | public init(@ParserBuilder value: () -> ValueParser) { 26 | self.valueParser = value() 27 | } 28 | 29 | /// Initializes a fragment parser. 30 | /// 31 | /// - Parameter value: A conversion that transforms the fragment's substring value into 32 | /// some other type. 33 | @inlinable 34 | public init(_ value: C) 35 | where ValueParser == Parsers.MapConversion>, C> { 36 | self.valueParser = Rest().replaceError(with: "").map(value) 37 | } 38 | 39 | @inlinable 40 | public func parse(_ input: inout URLRequestData) throws -> ValueParser.Output { 41 | guard var fragment = input.fragment?[...] else { throw RoutingError() } 42 | let output = try self.valueParser.parse(&fragment) 43 | input.fragment = String(fragment) 44 | return output 45 | } 46 | } 47 | 48 | extension Fragment: ParserPrinter where ValueParser: ParserPrinter { 49 | @inlinable 50 | public func print(_ output: ValueParser.Output, into input: inout URLRequestData) rethrows { 51 | input.fragment = String(try self.valueParser.print(output)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/URLRouting/Headers.swift: -------------------------------------------------------------------------------- 1 | /// Parses a request's headers using field parsers. 2 | public struct Headers: Parser 3 | where FieldParsers.Input == URLRequestData.Fields { 4 | @usableFromInline 5 | let fieldParsers: FieldParsers 6 | 7 | @inlinable 8 | public init(@ParserBuilder build: () -> FieldParsers) { 9 | self.fieldParsers = build() 10 | } 11 | 12 | @inlinable 13 | public func parse(_ input: inout URLRequestData) rethrows -> FieldParsers.Output { 14 | try self.fieldParsers.parse(&input.headers) 15 | } 16 | } 17 | 18 | extension Headers: ParserPrinter where FieldParsers: ParserPrinter { 19 | @inlinable 20 | public func print(_ output: FieldParsers.Output, into input: inout URLRequestData) rethrows { 21 | try self.fieldParsers.print(output, into: &input.headers) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/URLRouting/Host.swift: -------------------------------------------------------------------------------- 1 | /// Parses a request's host. 2 | /// 3 | /// Used to require a particular host at a particular endpoint. 4 | /// 5 | /// ```swift 6 | /// Route(.case(SiteRoute.custom)) { 7 | /// Host("custom") // Only routes scheme://custom requests 8 | /// ... 9 | /// } 10 | /// ``` 11 | /// 12 | /// > Note: Do not use the `Host` parser for the purpose of preferring to print a particular 13 | /// > host from your router. Instead, consider using ``BaseURLPrinter`` via the `baseURL` and 14 | /// > `baseRequestData` methods on routers. 15 | public struct Host: ParserPrinter { 16 | @usableFromInline 17 | let name: String 18 | 19 | /// A parser of custom hosts. 20 | public static func custom(_ host: String) -> Self { 21 | Self(host) 22 | } 23 | 24 | /// Initializes a host parser with a host name. 25 | /// 26 | /// - Parameter name: A host name. 27 | @inlinable 28 | public init(_ name: String) { 29 | self.name = name 30 | } 31 | 32 | @inlinable 33 | public func parse(_ input: inout URLRequestData) throws { 34 | guard let host = input.host else { throw RoutingError() } 35 | try self.name.parse(host) 36 | input.host = nil 37 | } 38 | 39 | @inlinable 40 | public func print(_ output: (), into input: inout URLRequestData) { 41 | input.host = self.name 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/URLRouting/Internal/AnyEquatable.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { 3 | func open(_: LHS.Type) -> Bool? { 4 | (Box.self as? AnyEquatable.Type)?.isEqual(lhs, rhs) 5 | } 6 | return _openExistential(type(of: lhs), do: open) ?? false 7 | } 8 | 9 | private enum Box {} 10 | 11 | private protocol AnyEquatable { 12 | static func isEqual(_ lhs: Any, _ rhs: Any) -> Bool 13 | } 14 | 15 | extension Box: AnyEquatable where T: Equatable { 16 | fileprivate static func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { 17 | lhs as? T == rhs as? T 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/URLRouting/Internal/Breakpoint.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Darwin) 2 | import Darwin 3 | #endif 4 | 5 | /// Raises a debug breakpoint if a debugger is attached. 6 | @inline(__always) 7 | @usableFromInline 8 | func breakpoint(_ message: @autoclosure () -> String = "") { 9 | #if canImport(Darwin) 10 | // https://github.com/bitstadium/HockeySDK-iOS/blob/c6e8d1e940299bec0c0585b1f7b86baf3b17fc82/Classes/BITHockeyHelper.m#L346-L370 11 | var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] 12 | var info: kinfo_proc = kinfo_proc() 13 | var info_size = MemoryLayout.size 14 | 15 | let isDebuggerAttached = 16 | sysctl(&name, 4, &info, &info_size, nil, 0) != -1 17 | && info.kp_proc.p_flag & P_TRACED != 0 18 | 19 | if isDebuggerAttached { 20 | fputs( 21 | """ 22 | \(message()) 23 | 24 | Caught debug breakpoint. Type "continue" ("c") to resume execution. 25 | 26 | """, 27 | stderr 28 | ) 29 | raise(SIGTRAP) 30 | } 31 | #else 32 | assertionFailure(message()) 33 | #endif 34 | } 35 | -------------------------------------------------------------------------------- /Sources/URLRouting/Internal/Deprecations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | // NB: Deprecated after 0.1.0: 8 | 9 | extension URLRoutingClient { 10 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 11 | @available(*, deprecated, renamed: "decodedResponse(for:as:decoder:)") 12 | public func request( 13 | _ route: Route, 14 | as type: Value.Type = Value.self, 15 | decoder: JSONDecoder? = nil 16 | ) async throws -> (value: Value, response: URLResponse) { 17 | try await self.decodedResponse(for: route, as: type, decoder: decoder) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/URLRouting/Method.swift: -------------------------------------------------------------------------------- 1 | /// Parses a request's method. 2 | /// 3 | /// Used to require a particular method at a particular endpoint. 4 | /// 5 | /// ```swift 6 | /// Route(.case(SiteRoute.login)) { 7 | /// Method.post // Only route POST requests 8 | /// ... 9 | /// } 10 | /// ``` 11 | public struct Method: ParserPrinter { 12 | @usableFromInline 13 | let name: String 14 | 15 | /// A parser of GET requests. 16 | /// 17 | /// Recognizes both HEAD and GET HTTP methods. 18 | /// 19 | /// > Note: If you are using a ``Route`` parser you do not need to specify `Method.get` (it is the 20 | /// > default). 21 | public static let get = OneOf { 22 | Self("HEAD") 23 | Self("GET") // NB: Prefer printing "GET" 24 | } 25 | 26 | /// A parser of POST requests. 27 | public static let post = Self("POST") 28 | 29 | /// A parser of PUT requests. 30 | public static let put = Self("PUT") 31 | 32 | /// A parser of PATCH requests. 33 | public static let patch = Self("PATCH") 34 | 35 | /// A parser of DELETE requests. 36 | public static let delete = Self("DELETE") 37 | 38 | /// Initializes a request method parser with a method name. 39 | /// 40 | /// - Parameter name: A method name. 41 | @inlinable 42 | public init(_ name: String) { 43 | self.name = name.uppercased() 44 | } 45 | 46 | @inlinable 47 | public func parse(_ input: inout URLRequestData) throws { 48 | guard let method = input.method else { throw RoutingError() } 49 | try self.name.parse(method) 50 | input.method = nil 51 | } 52 | 53 | @inlinable 54 | public func print(_ output: (), into input: inout URLRequestData) { 55 | input.method = self.name 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/URLRouting/Parsing/Parse.swift: -------------------------------------------------------------------------------- 1 | import Parsing 2 | 3 | extension Parse { 4 | @inlinable 5 | public init( 6 | _ conversion: Downstream 7 | ) where Parsers == Parsing.Parsers.MapConversion, Downstream> { 8 | self.init { Rest().map(conversion) } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/URLRouting/Parsing/ParserPrinter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | extension Parser where Input == URLRequestData { 8 | @inlinable 9 | public func match(request: URLRequest) throws -> Output { 10 | guard let data = URLRequestData(request: request) 11 | else { throw RoutingError() } 12 | return try self.parse(data) 13 | } 14 | 15 | @inlinable 16 | public func match(url: URL) throws -> Output { 17 | guard let data = URLRequestData(url: url) 18 | else { throw RoutingError() } 19 | return try self.parse(data) 20 | } 21 | 22 | @inlinable 23 | public func match(path: String) throws -> Output { 24 | guard let data = URLRequestData(string: path) 25 | else { throw RoutingError() } 26 | return try self.parse(data) 27 | } 28 | } 29 | 30 | extension ParserPrinter where Input == URLRequestData { 31 | @inlinable 32 | public func request(for route: Output) throws -> URLRequest { 33 | guard let request = try URLRequest(data: self.print(route)) 34 | else { throw RoutingError() } 35 | return request 36 | } 37 | 38 | @inlinable 39 | public func url(for route: Output) -> URL { 40 | do { 41 | return try URLComponents(data: self.print(route)).url ?? URL(string: "#route-not-found")! 42 | } catch { 43 | breakpoint( 44 | """ 45 | --- 46 | Could not generate a URL for route: 47 | 48 | \(route) 49 | 50 | The router has not been configured to parse this output and so it cannot print it back \ 51 | into a URL. A '#route-not-found' fragment has been printed instead. 52 | 53 | \(error) 54 | --- 55 | """ 56 | ) 57 | return URL(string: "#route-not-found")! 58 | } 59 | } 60 | 61 | @inlinable 62 | public func path(for route: Output) -> String { 63 | do { 64 | let data = try self.print(route) 65 | var components = URLComponents() 66 | components.path = "/\(data.path.joined(separator: "/"))" 67 | if !data.query.isEmpty { 68 | components.queryItems = data.query 69 | .flatMap { name, values in 70 | values.map { URLQueryItem(name: name, value: $0.map(String.init)) } 71 | } 72 | } 73 | return components.string ?? "#route-not-found" 74 | } catch { 75 | breakpoint( 76 | """ 77 | --- 78 | Could not generate a URL for route: 79 | 80 | \(route) 81 | 82 | The router has not been configured to parse this output and so it cannot print it back \ 83 | into a URL. A '#route-not-found' fragment has been printed instead. 84 | 85 | \(error) 86 | --- 87 | """ 88 | ) 89 | return "#route-not-found" 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/URLRouting/Path.swift: -------------------------------------------------------------------------------- 1 | /// Parses a request's path components. 2 | /// 3 | /// Useful for incrementally consuming path components from the beginning of a URL. 4 | /// 5 | /// For example, you could route to a particular user based off a path to their integer identifier: 6 | /// 7 | /// ```swift 8 | /// try Path { 9 | /// "users" 10 | /// Digits() 11 | /// } 12 | /// .match(path: "/users/42") 13 | /// // 42 14 | /// ``` 15 | public struct Path: Parser 16 | where ComponentParsers.Input == URLRequestData { 17 | @usableFromInline 18 | let componentParsers: ComponentParsers 19 | 20 | @inlinable 21 | public init(@PathBuilder build: () -> ComponentParsers) { 22 | self.componentParsers = build() 23 | } 24 | 25 | @inlinable 26 | public func parse(_ input: inout URLRequestData) rethrows -> ComponentParsers.Output { 27 | try self.componentParsers.parse(&input) 28 | } 29 | } 30 | 31 | extension Path: ParserPrinter where ComponentParsers: ParserPrinter { 32 | @inlinable 33 | public func print(_ output: ComponentParsers.Output, into input: inout URLRequestData) rethrows { 34 | try self.componentParsers.print(output, into: &input) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/URLRouting/PathBuilder.swift: -------------------------------------------------------------------------------- 1 | /// A custom parameter attribute that constructs path component parsers from closures. The 2 | /// constructed parser runs a number of parsers against each patch component, one after the other, 3 | /// and accumulates their outputs. 4 | /// 5 | /// The ``Path`` router acts as an entry point into `@PathBuilder` syntax, where you can list all of 6 | /// the path component parsers you want to run. For example, to route to a particular user by their 7 | /// integer identifier: 8 | /// 9 | /// ```swift 10 | /// try Path { 11 | /// "users" 12 | /// Digits() 13 | /// } 14 | /// .match(path: "/users/42") // 42 15 | /// ``` 16 | @resultBuilder 17 | public enum PathBuilder { 18 | @inlinable 19 | public static func buildBlock(_ parser: P) -> Component

{ 20 | .init(parser) 21 | } 22 | 23 | @inlinable 24 | public static func buildExpression(_ parser: P) -> P where P.Input == Substring { 25 | parser 26 | } 27 | 28 | @inlinable 29 | @_disfavoredOverload 30 | public static func buildExpression( 31 | _ parser: P 32 | ) -> From 33 | where P.Input == Substring.UTF8View { 34 | From(.utf8) { 35 | parser 36 | } 37 | } 38 | 39 | public struct Component: Parser 40 | where ComponentParser.Input == Substring { 41 | @usableFromInline 42 | let componentParser: ComponentParser 43 | 44 | @usableFromInline 45 | init(_ componentParser: ComponentParser) { 46 | self.componentParser = componentParser 47 | } 48 | 49 | @inlinable 50 | public func parse(_ input: inout URLRequestData) throws -> ComponentParser.Output { 51 | guard input.path.count >= 1 else { throw RoutingError() } 52 | return try self.componentParser.parse(input.path.removeFirst()) 53 | } 54 | } 55 | } 56 | 57 | extension PathBuilder.Component: ParserPrinter where ComponentParser: ParserPrinter { 58 | @inlinable 59 | public func print(_ output: ComponentParser.Output, into input: inout URLRequestData) rethrows { 60 | try input.path.prepend(self.componentParser.print(output)) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/URLRouting/Printing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OrderedCollections 3 | import Parsing 4 | 5 | extension ParserPrinter where Input == URLRequestData { 6 | /// Prepends a router with a base URL for the purpose of printing. 7 | /// 8 | /// Useful for printing absolute URLs to a specific scheme, domain, and path prefix. 9 | /// 10 | /// ```swift 11 | /// let apiRouter = router.baseURL("https://api.pointfree.co/v1") 12 | /// 13 | /// try apiRouter.print(.episodes(.episode(1, .index)) 14 | /// // https://api.pointfree.co/v1/episodes/1 15 | /// ``` 16 | /// 17 | /// - Parameter urlString: A base URL string. 18 | /// - Returns: A parser-printer that prepends a base URL to whatever this parser-printer prints. 19 | @inlinable 20 | public func baseURL(_ urlString: String) -> BaseURLPrinter { 21 | guard let defaultRequestData = URLRequestData(string: urlString) 22 | else { fatalError("Invalid base URL: \(urlString.debugDescription)") } 23 | return BaseURLPrinter(defaultRequestData: defaultRequestData, upstream: self) 24 | } 25 | 26 | /// Prepends a router with default request data for the purpose of printing. 27 | /// 28 | /// ```swift 29 | /// let authenticatedRouter = router 30 | /// .baseRequestData(.init(headers: ["X-PointFree-Session": ["deadbeef"]])) 31 | /// ``` 32 | /// 33 | /// - Parameter requestData: Default request data to print into. 34 | /// - Returns: A parser-printer that prints into some default request data. 35 | @inlinable 36 | public func baseRequestData(_ requestData: URLRequestData) -> BaseURLPrinter { 37 | BaseURLPrinter(defaultRequestData: requestData, upstream: self) 38 | } 39 | } 40 | 41 | /// Attaches base URL request data to a router. 42 | /// 43 | /// You will not typically need to interact with this type directly. Instead you will usually use 44 | /// the `baseURL` and `baseRequestData` operations on router, which constructs this type. 45 | /// 46 | /// ```swift 47 | /// let apiRouter = router.baseURL("https://api.pointfree.co/v1") 48 | /// 49 | /// apiRouter.url(for: .episodes(.episode(1, .index))) 50 | /// // https://api.pointfree.co/v1/episodes/1 51 | /// 52 | /// let authenticatedRouter = router 53 | /// .baseRequestData(.init(headers: ["X-PointFree-Session": ["deadbeef"]])) 54 | /// 55 | /// try authenticatedRouter.request(for: .home) 56 | /// .value(forHTTPHeaderField: "x-pointfree-session") 57 | /// // "deadbeef" 58 | /// ``` 59 | public struct BaseURLPrinter: ParserPrinter 60 | where Upstream.Input == URLRequestData { 61 | @usableFromInline 62 | let defaultRequestData: URLRequestData 63 | 64 | @usableFromInline 65 | let upstream: Upstream 66 | 67 | @usableFromInline 68 | init(defaultRequestData: URLRequestData, upstream: Upstream) { 69 | self.defaultRequestData = defaultRequestData 70 | self.upstream = upstream 71 | } 72 | 73 | @inlinable 74 | public func parse(_ input: inout URLRequestData) rethrows -> Upstream.Output { 75 | try self.upstream.parse(&input) 76 | } 77 | 78 | @inlinable 79 | public func print(_ output: Upstream.Output, into input: inout URLRequestData) rethrows { 80 | try self.upstream.print(output, into: &input) 81 | if let scheme = self.defaultRequestData.scheme { input.scheme = scheme } 82 | if let user = self.defaultRequestData.user { input.user = user } 83 | if let password = self.defaultRequestData.password { input.password = password } 84 | if let host = self.defaultRequestData.host { input.host = host } 85 | if let port = self.defaultRequestData.port { input.port = port } 86 | input.path.prepend(contentsOf: self.defaultRequestData.path) 87 | input.query.fields.merge(self.defaultRequestData.query.fields) { $1 + $0 } 88 | if let fragment = self.defaultRequestData.fragment { input.fragment = fragment } 89 | input.headers.fields.merge(self.defaultRequestData.headers.fields) { $1 + $0 } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/URLRouting/Query.swift: -------------------------------------------------------------------------------- 1 | /// Parses a request's query using field parsers. 2 | /// 3 | /// For example, a search endpoint may include a few query items, which can be specified as fields: 4 | /// 5 | /// ```swift 6 | /// Query { 7 | /// Field("q", .string, default: "") 8 | /// Field("page", default: 1) { 9 | /// Digits() 10 | /// } 11 | /// Field("per_page", default: 20) { 12 | /// Digits() 13 | /// } 14 | /// } 15 | /// ``` 16 | public struct Query: Parser 17 | where FieldParsers.Input == URLRequestData.Fields { 18 | @usableFromInline 19 | let fieldParsers: FieldParsers 20 | 21 | @inlinable 22 | public init(@ParserBuilder build: () -> FieldParsers) { 23 | self.fieldParsers = build() 24 | } 25 | 26 | @inlinable 27 | public func parse(_ input: inout URLRequestData) rethrows -> FieldParsers.Output { 28 | try self.fieldParsers.parse(&input.query) 29 | } 30 | } 31 | 32 | extension Query: ParserPrinter where FieldParsers: ParserPrinter { 33 | @inlinable 34 | public func print(_ output: FieldParsers.Output, into input: inout URLRequestData) rethrows { 35 | try self.fieldParsers.print(output, into: &input.query) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/URLRouting/Route.swift: -------------------------------------------------------------------------------- 1 | /// A parser that attempts to run a number of parsers to accumulate output associated with a 2 | /// particular URL endpoint. 3 | /// 4 | /// `Route` is a domain-specific version of `Parse`, suited to URL request routing. 5 | public struct Route: Parser where Parsers.Input == URLRequestData { 6 | @usableFromInline 7 | let parsers: Parsers 8 | 9 | @inlinable 10 | public init( 11 | _ transform: @escaping (Upstream.Output) -> NewOutput, 12 | @ParserBuilder with build: () -> Upstream 13 | ) 14 | where 15 | Upstream.Input == URLRequestData, 16 | Parsers == Parsing.Parsers.Map 17 | { 18 | self.parsers = build().map(transform) 19 | } 20 | 21 | @_disfavoredOverload 22 | @inlinable 23 | public init( 24 | _ output: NewOutput, 25 | @ParserBuilder with build: () -> Upstream 26 | ) 27 | where 28 | Upstream.Input == URLRequestData, 29 | Parsers == Parsing.Parsers.MapConstant 30 | { 31 | self.parsers = build().map { output } 32 | } 33 | 34 | @inlinable 35 | public init( 36 | _ output: NewOutput 37 | ) 38 | where 39 | Parsers == Parsing.Parsers.MapConstant, NewOutput> 40 | { 41 | self.init(output) { 42 | Always(()) 43 | } 44 | } 45 | 46 | @inlinable 47 | public init( 48 | _ conversion: C, 49 | @ParserBuilder with parsers: () -> P 50 | ) 51 | where 52 | P.Input == URLRequestData, 53 | Parsers == Parsing.Parsers.MapConversion 54 | { 55 | self.parsers = parsers().map(conversion) 56 | } 57 | 58 | @inlinable 59 | public init( 60 | _ conversion: C 61 | ) where Parsers == Parsing.Parsers.MapConversion, C> { 62 | self.init(conversion) { 63 | Always(()) 64 | } 65 | } 66 | 67 | @inlinable 68 | public func parse(_ input: inout URLRequestData) throws -> Parsers.Output { 69 | let output = try self.parsers.parse(&input) 70 | if input.method != nil { 71 | try Method.get.parse(&input) 72 | } 73 | try PathEnd().parse(input) 74 | return output 75 | } 76 | } 77 | 78 | extension Route: ParserPrinter where Parsers: ParserPrinter { 79 | @inlinable 80 | public func print(_ output: Parsers.Output, into input: inout URLRequestData) rethrows { 81 | try self.parsers.print(output, into: &input) 82 | } 83 | } 84 | 85 | @usableFromInline 86 | struct PathEnd: ParserPrinter { 87 | @inlinable 88 | public init() {} 89 | 90 | @inlinable 91 | public func parse(_ input: inout URLRequestData) throws { 92 | guard var first = input.path.first else { return } 93 | try End().parse(&first) 94 | } 95 | 96 | @inlinable 97 | public func print(_ output: (), into input: inout Input) throws { 98 | guard var first = input.path.first else { return } 99 | try End().print((), into: &first) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/URLRouting/Router.swift: -------------------------------------------------------------------------------- 1 | public typealias Router = ParserPrinter 2 | -------------------------------------------------------------------------------- /Sources/URLRouting/RoutingError.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | struct RoutingError: Error { 3 | @usableFromInline 4 | init() {} 5 | } 6 | -------------------------------------------------------------------------------- /Sources/URLRouting/Scheme.swift: -------------------------------------------------------------------------------- 1 | /// Parses a request's scheme. 2 | /// 3 | /// Used to require a particular scheme at a particular endpoint. 4 | /// 5 | /// ```swift 6 | /// Route(.case(SiteRoute.custom)) { 7 | /// Scheme("custom") // Only route custom:// requests 8 | /// ... 9 | /// } 10 | /// ``` 11 | /// 12 | /// > Note: Do not use the `Scheme` parser for the purpose of preferring to print a particular 13 | /// > scheme from your router. Instead, consider using ``BaseURLPrinter`` via the `baseURL` and 14 | /// > `baseRequestData` methods on routers. 15 | public struct Scheme: ParserPrinter { 16 | @usableFromInline 17 | let name: String 18 | 19 | /// A parser of the `http` scheme. 20 | public static let http = Self("http") 21 | 22 | /// A parser of the `https` scheme. 23 | public static let https = Self("https") 24 | 25 | /// Initializes a scheme parser with a scheme name. 26 | /// 27 | /// - Parameter name: A scheme name. 28 | @inlinable 29 | public init(_ name: String) { 30 | self.name = name 31 | } 32 | 33 | @inlinable 34 | public func parse(_ input: inout URLRequestData) throws { 35 | guard let scheme = input.scheme else { throw RoutingError() } 36 | try self.name.parse(scheme) 37 | input.scheme = nil 38 | } 39 | 40 | @inlinable 41 | public func print(_ output: (), into input: inout URLRequestData) { 42 | input.scheme = self.name 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/URLRouting/URLRequestData+Foundation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | extension URLRequestData { 8 | /// Initializes parseable request data from a `URLRequest`. 9 | /// 10 | /// Useful for converting a `URLRequest` from the Foundation framework into parseable 11 | /// ``URLRequestData``. 12 | /// 13 | /// ```swift 14 | /// guard let requestData = URLRequestData(request: urlRequest) 15 | /// else { return } 16 | /// 17 | /// let route = try router.parse(requestData) 18 | /// ``` 19 | /// 20 | /// - Parameter request: A URL request. 21 | public init?(request: URLRequest) { 22 | guard 23 | let url = request.url, 24 | let components = URLComponents(url: url, resolvingAgainstBaseURL: false) 25 | else { return nil } 26 | 27 | self.init( 28 | method: request.httpMethod, 29 | scheme: components.scheme, 30 | user: components.user, 31 | password: components.password, 32 | host: components.host, 33 | port: components.port, 34 | path: components.path, 35 | query: components.queryItems?.reduce(into: [:]) { query, item in 36 | query[item.name, default: []].append(item.value) 37 | } ?? [:], 38 | fragment: components.fragment, 39 | headers: .init( 40 | request.allHTTPHeaderFields?.map { key, value in 41 | (key, value.split(separator: ",", omittingEmptySubsequences: false).map { String($0) }) 42 | } ?? [], 43 | uniquingKeysWith: { $1 } 44 | ), 45 | body: request.httpBody 46 | ) 47 | } 48 | 49 | /// Initializes a parseable URL request from a `URL`. 50 | public init?(url: URL) { 51 | self.init(request: URLRequest(url: url)) 52 | } 53 | 54 | /// Initializes a parseable URL request from a URL string. 55 | public init?(string: String) { 56 | guard let url = URL(string: string) 57 | else { return nil } 58 | self.init(url: url) 59 | } 60 | } 61 | 62 | extension URLComponents { 63 | /// Initializes `URLComponents` from parseable/printable request data. 64 | /// 65 | /// Useful for converting ``URLRequestData`` into a `URL`. 66 | /// 67 | /// ```swift 68 | /// let requestData = try router.print(route) 69 | /// guard let urlRequest = URLRequest(data: requestData) 70 | /// else { return } 71 | /// ``` 72 | /// 73 | /// - Parameter data: URL request data. 74 | public init(data: URLRequestData) { 75 | self.init() 76 | self.scheme = data.scheme 77 | self.user = data.user 78 | self.password = data.password 79 | self.host = data.host 80 | self.port = data.port 81 | self.path = "/\(data.path.joined(separator: "/"))" 82 | if !data.query.isEmpty { 83 | self.queryItems = data.query 84 | .flatMap { name, values in 85 | values.map { URLQueryItem(name: name, value: $0.map(String.init)) } 86 | } 87 | } 88 | self.fragment = data.fragment 89 | } 90 | } 91 | 92 | extension URLRequest { 93 | /// Initializes a `URLRequest` from parseable/printable request data. 94 | /// 95 | /// Useful for converting ``URLRequestData`` back into a `URLRequest`. 96 | /// 97 | /// ```swift 98 | /// let requestData = try router.print(route) 99 | /// guard let urlRequest = URLRequest(data: requestData) 100 | /// else { return } 101 | /// ``` 102 | /// 103 | /// - Parameter data: URL request data. 104 | public init?(data: URLRequestData) { 105 | guard let url = URLComponents(data: data).url else { return nil } 106 | self.init(url: url) 107 | self.httpMethod = data.method 108 | for (name, values) in data.headers { 109 | for value in values { 110 | if let value = value { 111 | self.addValue(String(value), forHTTPHeaderField: name) 112 | } 113 | } 114 | } 115 | self.httpBody = data.body.map { Data($0) } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/URLRouting/URLRequestData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OrderedCollections 3 | 4 | /// A parseable URL request. 5 | /// 6 | /// Models a URL request in manner that can be incrementally parsed in an efficient way, by storing 7 | /// its various fields as subsequences for parsers to consume. 8 | public struct URLRequestData: Sendable, Equatable, _EmptyInitializable { 9 | /// The request body. 10 | public var body: Data? 11 | 12 | /// The fragment subcomponent of the request URL. 13 | public var fragment: String? 14 | 15 | /// The request headers. 16 | /// 17 | /// Modeled as ``Fields`` for efficient incremental parsing. 18 | /// 19 | /// To incrementally parse from these fields, use the ``Headers`` parser. 20 | public var headers: Fields = .init([:], isNameCaseSensitive: false) 21 | 22 | /// The host subcomponent of the request URL 23 | public var host: String? 24 | 25 | /// An HTTP method, _e.g._ `"GET"` or `"POST"`. 26 | /// 27 | /// To parse, use the ``Method`` parser. 28 | public var method: String? 29 | 30 | /// The password subcomponent of the request URL. 31 | public var password: String? 32 | 33 | /// An array of the request URL's path components. 34 | /// 35 | /// Modeled as an `ArraySlice` of `Substring`s for efficient incremental parsing. 36 | public var path: ArraySlice = [] 37 | 38 | /// The port subcomponent of the request URL. 39 | public var port: Int? 40 | 41 | /// The query subcomponent of the request URL. 42 | /// 43 | /// Modeled as ``Fields`` for efficient incremental parsing. 44 | /// 45 | /// To incrementally parse from these fields, use the ``Headers`` parser. 46 | public var query: Fields = .init([:], isNameCaseSensitive: true) 47 | 48 | /// The scheme, _e.g._ `"https"` or `"http"`. 49 | public var scheme: String? 50 | 51 | /// The user subcomponent of the request URL. 52 | public var user: String? 53 | 54 | /// Initializes an empty URL request. 55 | public init() {} 56 | 57 | /// Initializes a URL request. 58 | /// 59 | /// - Parameters: 60 | /// - method: The HTTP method, _e.g._ `"GET"` or `"POST"`. 61 | /// - scheme: The scheme, _e.g._ `"https"` or `"http"`. 62 | /// - user: The user subcomponent of the request URL. 63 | /// - password: The password subcomponent of the request URL. 64 | /// - host: The host subcomponent of the request URL. 65 | /// - port: The port subcomponent of the request URL. 66 | /// - path: An array of the request URL's path components. 67 | /// - query: The query subcomponent of the request URL. 68 | /// - fragment: The fragment subcomponent of the request URL. 69 | /// - headers: The request headers. 70 | /// - body: The request body. 71 | @inlinable 72 | public init( 73 | method: String? = nil, 74 | scheme: String? = nil, 75 | user: String? = nil, 76 | password: String? = nil, 77 | host: String? = nil, 78 | port: Int? = nil, 79 | path: String = "", 80 | query: OrderedDictionary = [:], 81 | fragment: String? = nil, 82 | headers: OrderedDictionary = [:], 83 | body: Data? = nil 84 | ) { 85 | self.body = body 86 | self.fragment = fragment 87 | self.headers = .init(headers.mapValues { $0.map { $0?[...] }[...] }, isNameCaseSensitive: false) 88 | self.host = host 89 | self.method = method 90 | self.password = password 91 | self.path = path.split(separator: "/")[...] 92 | self.port = port 93 | self.query = .init(query.mapValues { $0.map { $0?[...] }[...] }, isNameCaseSensitive: true) 94 | self.scheme = scheme 95 | self.user = user 96 | } 97 | 98 | /// A wrapper around a dictionary of strings to array slices of substrings. 99 | /// 100 | /// Used by ``URLRequestData`` to model query parameters and headers in a way that can be 101 | /// efficiently parsed. 102 | public struct Fields: Sendable { 103 | public var fields: OrderedDictionary> 104 | 105 | @usableFromInline var isNameCaseSensitive: Bool 106 | 107 | @inlinable 108 | public init( 109 | _ fields: OrderedDictionary> = [:], 110 | isNameCaseSensitive: Bool 111 | ) { 112 | self.fields = [:] 113 | self.fields.reserveCapacity(fields.count) 114 | self.isNameCaseSensitive = isNameCaseSensitive 115 | for (key, value) in fields { 116 | self[key] = value 117 | } 118 | } 119 | 120 | @inlinable 121 | public subscript(name: String) -> ArraySlice? { 122 | _read { yield self.fields[self.isNameCaseSensitive ? name : name.lowercased()] } 123 | _modify { yield &self.fields[self.isNameCaseSensitive ? name : name.lowercased()] } 124 | } 125 | 126 | @inlinable 127 | public subscript( 128 | name: String, default defaultValue: @autoclosure () -> ArraySlice 129 | ) -> ArraySlice { 130 | _read { 131 | yield self.fields[ 132 | self.isNameCaseSensitive ? name : name.lowercased(), default: defaultValue() 133 | ] 134 | } 135 | _modify { 136 | yield &self.fields[ 137 | self.isNameCaseSensitive ? name : name.lowercased(), default: defaultValue() 138 | ] 139 | } 140 | } 141 | } 142 | } 143 | 144 | extension URLRequestData: Codable { 145 | @inlinable 146 | public init(from decoder: Decoder) throws { 147 | let container = try decoder.container(keyedBy: CodingKeys.self) 148 | self.init( 149 | method: try container.decodeIfPresent(String.self, forKey: .method), 150 | scheme: try container.decodeIfPresent(String.self, forKey: .scheme), 151 | user: try container.decodeIfPresent(String.self, forKey: .user), 152 | password: try container.decodeIfPresent(String.self, forKey: .password), 153 | host: try container.decodeIfPresent(String.self, forKey: .host), 154 | port: try container.decodeIfPresent(Int.self, forKey: .port), 155 | path: try container.decodeIfPresent(String.self, forKey: .path) ?? "", 156 | query: try container.decodeIfPresent( 157 | OrderedDictionary.self, forKey: .query) ?? [:], 158 | fragment: try container.decodeIfPresent(String.self, forKey: .fragment), 159 | headers: try container.decodeIfPresent( 160 | OrderedDictionary.self, forKey: .headers) ?? [:], 161 | body: try container.decodeIfPresent(Data.self, forKey: .body) 162 | ) 163 | } 164 | 165 | @inlinable 166 | public func encode(to encoder: Encoder) throws { 167 | var container = encoder.container(keyedBy: CodingKeys.self) 168 | try container.encodeIfPresent(self.body.map(Array.init), forKey: .body) 169 | try container.encodeIfPresent(self.fragment, forKey: .fragment) 170 | if !self.headers.isEmpty { 171 | try container.encode( 172 | self.headers.fields.mapValues { $0.map { $0.map(String.init) } }, 173 | forKey: .headers 174 | ) 175 | } 176 | try container.encodeIfPresent(self.host, forKey: .host) 177 | try container.encodeIfPresent(self.method, forKey: .method) 178 | try container.encodeIfPresent(self.password, forKey: .password) 179 | if !self.path.isEmpty { try container.encode(self.path.joined(separator: "/"), forKey: .path) } 180 | try container.encodeIfPresent(self.port, forKey: .port) 181 | if !self.query.isEmpty { 182 | try container.encode( 183 | self.query.fields.mapValues { $0.map { $0.map(String.init) } }, 184 | forKey: .query 185 | ) 186 | } 187 | try container.encodeIfPresent(self.scheme, forKey: .scheme) 188 | try container.encodeIfPresent(self.user, forKey: .user) 189 | } 190 | 191 | @usableFromInline 192 | enum CodingKeys: CodingKey { 193 | case body 194 | case fragment 195 | case headers 196 | case host 197 | case method 198 | case password 199 | case path 200 | case port 201 | case query 202 | case scheme 203 | case user 204 | } 205 | } 206 | 207 | extension URLRequestData: Hashable { 208 | @inlinable 209 | public func hash(into hasher: inout Hasher) { 210 | hasher.combine(self.body) 211 | hasher.combine(self.fragment) 212 | hasher.combine(self.method) 213 | hasher.combine(self.headers) 214 | hasher.combine(self.host) 215 | hasher.combine(self.password) 216 | hasher.combine(self.path) 217 | hasher.combine(self.port) 218 | hasher.combine(self.query) 219 | hasher.combine(self.scheme) 220 | hasher.combine(self.user) 221 | } 222 | } 223 | 224 | extension URLRequestData.Fields: Collection { 225 | public typealias Element = OrderedDictionary>.Element 226 | public typealias Index = OrderedDictionary>.Index 227 | 228 | @inlinable 229 | public var startIndex: Index { 230 | self.fields.elements.startIndex 231 | } 232 | 233 | @inlinable 234 | public var endIndex: Index { 235 | self.fields.elements.endIndex 236 | } 237 | 238 | @inlinable 239 | public subscript(position: Index) -> Element { 240 | self.fields.elements[position] 241 | } 242 | 243 | @inlinable 244 | public func index(after i: Index) -> Index { 245 | self.fields.elements.index(after: i) 246 | } 247 | } 248 | 249 | extension URLRequestData.Fields: ExpressibleByDictionaryLiteral { 250 | @inlinable 251 | public init(dictionaryLiteral elements: (String, ArraySlice)...) { 252 | self.init(.init(elements) { $0 + $1 }, isNameCaseSensitive: true) 253 | } 254 | } 255 | 256 | extension URLRequestData.Fields: Equatable { 257 | @inlinable 258 | public static func == (lhs: Self, rhs: Self) -> Bool { 259 | lhs.fields == rhs.fields 260 | } 261 | } 262 | 263 | extension URLRequestData.Fields: Hashable { 264 | @inlinable 265 | public func hash(into hasher: inout Hasher) { 266 | hasher.combine(self.fields) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /Sources/swift-url-routing-benchmark/Common/Benchmarking.swift: -------------------------------------------------------------------------------- 1 | import Benchmark 2 | 3 | extension BenchmarkSuite { 4 | func benchmark( 5 | _ name: String, 6 | run: @escaping () throws -> Void, 7 | setUp: @escaping () -> Void = {}, 8 | tearDown: @escaping () -> Void 9 | ) { 10 | self.register( 11 | benchmark: Benchmarking(name: name, run: run, setUp: setUp, tearDown: tearDown) 12 | ) 13 | } 14 | } 15 | 16 | struct Benchmarking: AnyBenchmark { 17 | let name: String 18 | let settings: [BenchmarkSetting] = [] 19 | private let _run: () throws -> Void 20 | private let _setUp: () -> Void 21 | private let _tearDown: () -> Void 22 | 23 | init( 24 | name: String, 25 | run: @escaping () throws -> Void, 26 | setUp: @escaping () -> Void = {}, 27 | tearDown: @escaping () -> Void = {} 28 | ) { 29 | self.name = name 30 | self._run = run 31 | self._setUp = setUp 32 | self._tearDown = tearDown 33 | } 34 | 35 | func setUp() { 36 | self._setUp() 37 | } 38 | 39 | func run(_ state: inout BenchmarkState) throws { 40 | try self._run() 41 | } 42 | 43 | func tearDown() { 44 | self._tearDown() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/swift-url-routing-benchmark/Routing.swift: -------------------------------------------------------------------------------- 1 | import Benchmark 2 | import Foundation 3 | import URLRouting 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | /// This benchmark demonstrates how you can build a URL request router that can transform an input 10 | /// request into a more well-structured data type, such as an enum. We build a router that can 11 | /// recognize one of 5 routes for a website. 12 | let routingSuite = BenchmarkSuite(name: "Routing") { suite in 13 | if #available(macOS 10.13, *) { 14 | #if compiler(>=5.5) 15 | enum AppRoute: Equatable { 16 | case home 17 | case contactUs 18 | case episodes(EpisodesRoute) 19 | } 20 | enum EpisodesRoute: Equatable { 21 | case index 22 | case episode(id: Int, route: EpisodeRoute) 23 | } 24 | enum EpisodeRoute: Equatable { 25 | case show 26 | case comments(CommentsRoute) 27 | } 28 | enum CommentsRoute: Equatable { 29 | case post(Comment) 30 | case show(count: Int) 31 | } 32 | struct Comment: Codable, Equatable { 33 | let commenter: String 34 | let message: String 35 | } 36 | 37 | let encoder = JSONEncoder() 38 | encoder.outputFormatting = .sortedKeys 39 | 40 | let commentsRouter = OneOf { 41 | Route(.case(CommentsRoute.post)) { 42 | Method.post 43 | Body(.json(Comment.self, encoder: encoder)) 44 | } 45 | 46 | Route(.case(CommentsRoute.show)) { 47 | Query { 48 | Field("count", default: 10) { Digits() } 49 | } 50 | } 51 | } 52 | 53 | let episodeRouter = OneOf { 54 | Route(EpisodeRoute.show) 55 | 56 | Route(.case(EpisodeRoute.comments)) { 57 | Path { From(.utf8) { "comments".utf8 } } 58 | 59 | commentsRouter 60 | } 61 | } 62 | 63 | let episodesRouter = OneOf { 64 | Route(EpisodesRoute.index) 65 | 66 | Route(.case(EpisodesRoute.episode)) { 67 | Path { Digits() } 68 | 69 | episodeRouter 70 | } 71 | } 72 | 73 | let router = OneOf { 74 | Route(AppRoute.home) 75 | 76 | Route(AppRoute.contactUs) { 77 | Path { From(.utf8) { "contact-us".utf8 } } 78 | } 79 | 80 | Route(.case(AppRoute.episodes)) { 81 | Path { From(.utf8) { "episodes".utf8 } } 82 | 83 | episodesRouter 84 | } 85 | } 86 | 87 | let requests = [ 88 | URLRequestData(), 89 | URLRequestData(path: "/contact-us"), 90 | URLRequestData(path: "/episodes"), 91 | URLRequestData(path: "/episodes/1"), 92 | URLRequestData(path: "/episodes/1/comments"), 93 | URLRequestData(path: "/episodes/1/comments", query: ["count": ["20"]]), 94 | URLRequestData( 95 | method: "POST", 96 | path: "/episodes/1/comments", 97 | body: .init(#"{"commenter":"Blob","message":"Hi!"}"#.utf8) 98 | ), 99 | ] 100 | 101 | var output: [AppRoute]! 102 | var expectedOutput: [AppRoute] = [ 103 | .home, 104 | .contactUs, 105 | .episodes(.index), 106 | .episodes(.episode(id: 1, route: .show)), 107 | .episodes(.episode(id: 1, route: .comments(.show(count: 10)))), 108 | .episodes(.episode(id: 1, route: .comments(.show(count: 20)))), 109 | .episodes( 110 | .episode(id: 1, route: .comments(.post(.init(commenter: "Blob", message: "Hi!"))))), 111 | ] 112 | suite.benchmark("Parser") { 113 | output = try requests.map { 114 | var input = $0 115 | return try router.parse(&input) 116 | } 117 | } tearDown: { 118 | precondition(output == expectedOutput) 119 | precondition(requests == output.map { try! router.print($0) }) 120 | } 121 | #endif 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/swift-url-routing-benchmark/main.swift: -------------------------------------------------------------------------------- 1 | import Benchmark 2 | 3 | Benchmark.main( 4 | [ 5 | defaultBenchmarkSuite, 6 | routingSuite, 7 | ] 8 | ) 9 | -------------------------------------------------------------------------------- /Sources/variadics-generator/VariadicsGenerator.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift.org open source project 4 | // 5 | // Copyright (c) 2021 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See https://swift.org/LICENSE.txt for license information 9 | // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 10 | // 11 | //===----------------------------------------------------------------------===// 12 | 13 | import ArgumentParser 14 | 15 | struct Permutation { 16 | let arity: Int 17 | // 1 -> 18 | // 0 -> where P.Output == Void 19 | let bits: Int64 20 | 21 | func isCaptureless(at index: Int) -> Bool { 22 | bits & (1 << (-index + arity - 1)) != 0 23 | } 24 | 25 | var hasCaptureless: Bool { 26 | bits != 0 27 | } 28 | 29 | var identifier: String { 30 | var result = "" 31 | for i in 0.. Permutation? { 54 | guard counter & (1 << arity) == 0 else { 55 | return nil 56 | } 57 | defer { counter += 1 } 58 | return Permutation(arity: arity, bits: counter) 59 | } 60 | } 61 | 62 | public func makeIterator() -> Iterator { 63 | Iterator(arity: arity) 64 | } 65 | } 66 | 67 | func output(_ content: String) { 68 | print(content, terminator: "") 69 | } 70 | 71 | func outputForEach( 72 | _ elements: C, separator: String, _ content: (C.Element) -> String 73 | ) { 74 | for i in elements.indices { 75 | output(content(elements[i])) 76 | if elements.index(after: i) != elements.endIndex { 77 | output(separator) 78 | } 79 | } 80 | } 81 | 82 | struct VariadicsGenerator: ParsableCommand { 83 | func run() throws { 84 | output("// BEGIN AUTO-GENERATED CONTENT\n\n") 85 | 86 | for arity in 2...6 { 87 | emitPathZipDeclarations(arity: arity) 88 | } 89 | 90 | output("// END AUTO-GENERATED CONTENT\n") 91 | } 92 | 93 | func emitPathZipDeclarations(arity: Int) { 94 | for permutation in Permutations(arity: arity) { 95 | // Emit type declaration. 96 | let typeName = "PathZip\(permutation.identifier)" 97 | output("extension PathBuilder {\n public struct \(typeName)<") 98 | outputForEach(0..: Parser\nwhere\n ") 100 | outputForEach(0.. (\n" 115 | ) 116 | outputForEach(permutation.captureIndices, separator: ",\n") { " P\($0).Output" } 117 | output("\n ) {\n guard input.path.count >= \(arity) else { throw RoutingError() }") 118 | output("\n ") 119 | outputForEach(0..(\n ") 157 | outputForEach(0.. \(typeName)<") 159 | outputForEach(0.. {\n") 161 | output(" \(typeName)(") 162 | outputForEach(0.. { 12 | Route(.case(BookRoute.fetch)) 13 | } 14 | } 15 | 16 | struct Options { 17 | var sort: Sort = .name 18 | var direction: Direction = .asc 19 | var count: Int = 10 20 | 21 | enum Direction: String, CaseIterable, Decodable { 22 | case asc, desc 23 | } 24 | enum Sort: String, CaseIterable, Decodable { 25 | case name 26 | case category = "category" 27 | } 28 | } 29 | enum BooksRoute { 30 | case book(id: UUID, route: BookRoute) 31 | case search(Options) 32 | } 33 | struct BooksRouter: ParserPrinter { 34 | var body: some Router { 35 | OneOf { 36 | Route(.case(BooksRoute.book(id:route:))) { 37 | Path { UUID.parser() } 38 | BookRouter() 39 | } 40 | Route(.case(BooksRoute.search)) { 41 | Path { "search" } 42 | Parse(.memberwise(Options.init(sort:direction:count:))) { 43 | Query { 44 | Field("sort", default: .name) { Options.Sort.parser() } 45 | Field("direction", default: .asc) { Options.Direction.parser() } 46 | Field("count", default: 10) { Int.parser() } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | enum UserRoute { 55 | case books(BooksRoute) 56 | case fetch 57 | } 58 | struct UserRouter: ParserPrinter { 59 | var body: some Router { 60 | OneOf { 61 | Route(.case(UserRoute.books)) { 62 | Path { "books" } 63 | BooksRouter() 64 | } 65 | 66 | Route(.case(UserRoute.fetch)) 67 | } 68 | } 69 | } 70 | 71 | struct CreateUser: Codable { 72 | let bio: String 73 | let name: String 74 | } 75 | enum UsersRoute { 76 | case create(CreateUser) 77 | case user(id: Int, route: UserRoute) 78 | } 79 | struct UsersRouter: ParserPrinter { 80 | var body: some Router { 81 | OneOf { 82 | Route(.case(UsersRoute.create)) { 83 | Method.post 84 | Body(.json(CreateUser.self)) 85 | } 86 | 87 | Route(.case(UsersRoute.user(id:route:))) { 88 | Path { Int.parser() } 89 | UserRouter() 90 | } 91 | } 92 | } 93 | } 94 | 95 | enum SiteRoute { 96 | case aboutUs 97 | case contactUs 98 | case home 99 | case users(UsersRoute) 100 | } 101 | struct SiteRouter: ParserPrinter { 102 | var body: some Router { 103 | OneOf { 104 | Route(.case(SiteRoute.aboutUs)) { 105 | Path { "about-us" } 106 | } 107 | Route(.case(SiteRoute.contactUs)) { 108 | Path { "contact-us" } 109 | } 110 | Route(.case(SiteRoute.home)) 111 | 112 | Route(.case(SiteRoute.users)) { 113 | Path { "users" } 114 | UsersRouter() 115 | } 116 | } 117 | } 118 | } 119 | 120 | XCTAssertThrowsError(try SiteRouter().parse(URLRequestData(path: "/123"))) { error in 121 | XCTAssertEqual( 122 | """ 123 | error: unexpected input 124 | --> input:1:2 125 | 1 | /123 126 | | ^ expected "about-us" 127 | | ^ expected "contact-us" 128 | | ^ expected end of input 129 | | ^ expected "users" 130 | """, 131 | "\(error)" 132 | ) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Tests/URLRoutingTests/URLRoutingClientTests.swift: -------------------------------------------------------------------------------- 1 | import Parsing 2 | import URLRouting 3 | import XCTest 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | class URLRoutingClientTests: XCTestCase { 10 | #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) 11 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 12 | func testJSONDecoder_noDecoder() async throws { 13 | struct Response: Equatable, Decodable { 14 | let decodableValue: String 15 | } 16 | enum AppRoute { 17 | case test 18 | } 19 | let sut = URLRoutingClient(request: { _ in 20 | ("{\"decodableValue\":\"result\"}".data(using: .utf8)!, URLResponse()) 21 | }) 22 | let response = try await sut.decodedResponse(for: .test, as: Response.self) 23 | XCTAssertEqual(response.value, .init(decodableValue: "result")) 24 | } 25 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 26 | func testJSONDecoder_customDecoder() async throws { 27 | struct Response: Equatable, Decodable { 28 | let decodableValue: String 29 | } 30 | enum AppRoute { 31 | case test 32 | } 33 | let customDecoder = JSONDecoder() 34 | customDecoder.keyDecodingStrategy = .convertFromSnakeCase 35 | let sut = URLRoutingClient( 36 | request: { _ in 37 | ("{\"decodable_value\":\"result\"}".data(using: .utf8)!, URLResponse()) 38 | }, decoder: customDecoder) 39 | let response = try await sut.decodedResponse(for: .test, as: Response.self) 40 | XCTAssertEqual(response.value, .init(decodableValue: "result")) 41 | } 42 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 43 | func testJSONDecoder_customDecoderForRequest() async throws { 44 | struct Response: Equatable, Decodable { 45 | let decodableValue: String 46 | } 47 | enum AppRoute { 48 | case test 49 | } 50 | let customDecoder = JSONDecoder() 51 | customDecoder.keyDecodingStrategy = .convertFromSnakeCase 52 | let sut = URLRoutingClient( 53 | request: { _ in 54 | ("{\"decodableValue\":\"result\"}".data(using: .utf8)!, URLResponse()) 55 | }, decoder: customDecoder) 56 | let response = try await sut.decodedResponse(for: .test, as: Response.self, decoder: .init()) 57 | XCTAssertEqual(response.value, .init(decodableValue: "result")) 58 | } 59 | #endif 60 | } 61 | -------------------------------------------------------------------------------- /Tests/URLRoutingTests/URLRoutingTests.swift: -------------------------------------------------------------------------------- 1 | import Parsing 2 | import URLRouting 3 | import XCTest 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | class URLRoutingTests: XCTestCase { 10 | func testMethod() { 11 | XCTAssertNoThrow(try Method.post.parse(URLRequestData(method: "POST"))) 12 | XCTAssertEqual(try Method.post.print(), URLRequestData(method: "POST")) 13 | } 14 | 15 | func testHost() { 16 | XCTAssertNoThrow(try Host.custom("foo").parse(URLRequestData(host: "foo"))) 17 | XCTAssertEqual(try Host.custom("foo").print(), URLRequestData(host: "foo")) 18 | } 19 | 20 | func testScheme() { 21 | XCTAssertNoThrow(try Scheme.http.parse(URLRequestData(scheme: "http"))) 22 | XCTAssertEqual(try Scheme.http.print(), URLRequestData(scheme: "http")) 23 | } 24 | 25 | func testPath() { 26 | XCTAssertEqual(123, try Path { Int.parser() }.parse(URLRequestData(path: "/123"))) 27 | XCTAssertThrowsError(try Path { Int.parser() }.parse(URLRequestData(path: "/123-foo"))) { 28 | error in 29 | XCTAssertEqual( 30 | """ 31 | error: unexpected input 32 | --> input:1:5 33 | 1 | /123-foo 34 | | ^ expected end of input 35 | """, 36 | "\(error)" 37 | ) 38 | } 39 | } 40 | 41 | func testFormData() throws { 42 | let p = Body { 43 | FormData { 44 | Field("name", .string) 45 | Field("age") { Int.parser() } 46 | } 47 | } 48 | 49 | var request = URLRequestData(body: .init("name=Blob&age=42&debug=1".utf8)) 50 | let (name, age) = try p.parse(&request) 51 | XCTAssertEqual("Blob", name) 52 | XCTAssertEqual(42, age) 53 | XCTAssertEqual("debug=1", request.body.map { String(decoding: $0, as: UTF8.self) }) 54 | } 55 | 56 | func testHeaders() throws { 57 | let p = Headers { 58 | Field("X-Haha", .string) 59 | } 60 | 61 | var req = URLRequest(url: URL(string: "/")!) 62 | req.addValue("Hello", forHTTPHeaderField: "X-Haha") 63 | req.addValue("Blob", forHTTPHeaderField: "X-Haha") 64 | var request = URLRequestData(request: req)! 65 | 66 | let name = try p.parse(&request) 67 | XCTAssertEqual("Hello", name) 68 | XCTAssertEqual(["x-haha": ["Blob"]], request.headers) 69 | } 70 | 71 | func testQuery() throws { 72 | let p = Query { 73 | Field("name") 74 | Field("age") { Int.parser() } 75 | } 76 | 77 | var request = URLRequestData(string: "/?name=Blob&age=42&debug=1")! 78 | let (name, age) = try p.parse(&request) 79 | XCTAssertEqual("Blob", name) 80 | XCTAssertEqual(42, age) 81 | XCTAssertEqual(["debug": ["1"]], request.query) 82 | 83 | XCTAssertEqual( 84 | try p.print(("Blob", 42)), 85 | URLRequestData(query: ["name": ["Blob"], "age": ["42"]]) 86 | ) 87 | } 88 | 89 | func testQueryDefault() throws { 90 | let p = Query { 91 | Field("page", default: 1) { 92 | Int.parser() 93 | } 94 | } 95 | 96 | var request = URLRequestData(string: "/")! 97 | let page = try p.parse(&request) 98 | XCTAssertEqual(1, page) 99 | XCTAssertEqual([:], request.query) 100 | 101 | XCTAssertEqual( 102 | try p.print(10), 103 | URLRequestData(query: ["page": ["10"]]) 104 | ) 105 | XCTAssertEqual( 106 | try p.print(1), 107 | URLRequestData(query: [:]) 108 | ) 109 | } 110 | 111 | func testFragment() throws { 112 | // test default initializer 113 | let q1 = Fragment() 114 | 115 | var request = try XCTUnwrap(URLRequestData(string: "#fragment")) 116 | XCTAssertEqual( 117 | "fragment", 118 | try q1.parse(&request) 119 | ) 120 | XCTAssertEqual( 121 | URLRequestData(fragment: "fragment"), 122 | try q1.print("fragment") 123 | ) 124 | 125 | struct Timestamp: Equatable, RawRepresentable { 126 | let rawValue: String 127 | } 128 | 129 | // test conversion initializer 130 | let q2 = Fragment(.string.representing(Timestamp.self)) 131 | request = try XCTUnwrap( 132 | URLRequestData(string: "https://www.pointfree.co/episodes/ep182-invertible-parsing-map#t802")) 133 | XCTAssertEqual( 134 | Timestamp(rawValue: "t802"), 135 | try q2.parse(&request) 136 | ) 137 | XCTAssertEqual( 138 | URLRequestData(fragment: "t802"), 139 | try q2.print(Timestamp(rawValue: "t802")) 140 | ) 141 | 142 | // test parser builder initializer 143 | let p3 = Fragment { 144 | "section1" 145 | } 146 | 147 | request = try XCTUnwrap(URLRequestData(string: "#section1")) 148 | XCTAssertNoThrow(try p3.parse(&request)) 149 | request = try XCTUnwrap(URLRequestData(string: "#section2")) 150 | XCTAssertThrowsError(try p3.parse(&request)) 151 | XCTAssertEqual( 152 | .init(fragment: "section1"), 153 | try p3.print() 154 | ) 155 | 156 | enum AppRoute: Equatable { 157 | case privacyPolicy(section: String) 158 | } 159 | 160 | // routing example 161 | let r = Route(.case(AppRoute.privacyPolicy(section:))) { 162 | Path { 163 | "legal" 164 | "privacy" 165 | } 166 | Fragment() 167 | } 168 | 169 | request = try XCTUnwrap(URLRequestData(string: "/legal/privacy#faq")) 170 | XCTAssertEqual( 171 | .privacyPolicy(section: "faq"), 172 | try r.parse(&request) 173 | ) 174 | XCTAssertEqual( 175 | .init(path: "/legal/privacy", fragment: "faq"), 176 | try r.print(.privacyPolicy(section: "faq")) 177 | ) 178 | } 179 | 180 | func testCookies() throws { 181 | struct Session: Equatable { 182 | var userId: Int 183 | var isAdmin: Bool 184 | } 185 | 186 | let p = Cookies /*(.destructure(Session.init(userId:isAdmin:)))*/ { 187 | Field("userId") { Int.parser() } 188 | Field("isAdmin") { Bool.parser() } 189 | } 190 | .map(.memberwise(Session.init(userId:isAdmin:))) 191 | 192 | var request = URLRequestData(headers: ["cookie": ["userId=42; isAdmin=true"]]) 193 | XCTAssertEqual( 194 | Session(userId: 42, isAdmin: true), 195 | try p.parse(&request) 196 | ) 197 | XCTAssertEqual( 198 | URLRequestData(headers: ["cookie": ["userId=42; isAdmin=true"]]), 199 | try p.print(Session(userId: 42, isAdmin: true)) 200 | ) 201 | } 202 | 203 | func testJSONCookies() { 204 | struct Session: Codable, Equatable { 205 | var userId: Int 206 | } 207 | 208 | let p = Cookies { 209 | Field("pf_session", .utf8.data.json(Session.self)) 210 | } 211 | 212 | var request = URLRequestData(headers: ["cookie": [#"pf_session={"userId":42}; foo=bar"#]]) 213 | XCTAssertEqual( 214 | Session(userId: 42), 215 | try p.parse(&request) 216 | ) 217 | XCTAssertEqual( 218 | URLRequestData(headers: ["cookie": [#"pf_session={"userId":42}"#]]), 219 | try p.print(Session(userId: 42)) 220 | ) 221 | } 222 | 223 | func testBaseURL() throws { 224 | enum AppRoute { case home, episodes } 225 | 226 | let router = OneOf { 227 | Route(AppRoute.home) 228 | Route(AppRoute.episodes) { 229 | Path { "episodes" } 230 | } 231 | } 232 | 233 | XCTAssertEqual( 234 | "https://api.pointfree.co/v1/episodes?token=deadbeef", 235 | URLRequest( 236 | data: 237 | try router 238 | .baseURL("https://api.pointfree.co/v1?token=deadbeef") 239 | .print(.episodes) 240 | )?.url?.absoluteString 241 | ) 242 | 243 | XCTAssertEqual( 244 | "http://localhost:8080/v1/episodes?token=deadbeef", 245 | URLRequest( 246 | data: 247 | try router 248 | .baseURL("http://localhost:8080/v1?token=deadbeef") 249 | .print(.episodes) 250 | )?.url?.absoluteString 251 | ) 252 | } 253 | } 254 | --------------------------------------------------------------------------------