diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..8d2efaef2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + macos_tests: + runs-on: macos-12 + strategy: + matrix: + xcode: + - "13.2.1" # Swift 5.5 + command: + - test + steps: + - uses: actions/checkout@v2 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: System + run: system_profiler SPHardwareDataType + - name: Run ${{ matrix.command }} + run: make ${{ matrix.command }} + + ubuntu_tests: + strategy: + matrix: + os: [ubuntu-18.04, ubuntu-20.04] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + - name: Build + run: swift build + - name: Run tests + run: swift test diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000000..cab595310a --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,112 @@ +# Build and deploy DocC to GitHub pages. Based off of @karwa's work here: +# https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml +name: Documentation + +on: + release: + types: + - published + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Package + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Download Swift 5.5.1 + run: wget -q https://download.swift.org/swift-5.5.1-release/ubuntu2004/swift-5.5.1-RELEASE/swift-5.5.1-RELEASE-ubuntu20.04.tar.gz + - name: Extract Swift 5.5.1 + run: tar xzf swift-5.5.1-RELEASE-ubuntu20.04.tar.gz + - name: Add Swift toolchain to PATH + run: | + echo "$GITHUB_WORKSPACE/swift-5.5.1-RELEASE-ubuntu20.04/usr/bin" >> $GITHUB_PATH + + - name: Checkout swift-docc + uses: actions/checkout@v2 + with: + repository: apple/swift-docc + ref: main + path: swift-docc + - name: Cache DocC + id: cache-docc + uses: actions/cache@v2 + with: + key: swift-url-docc-build + path: swift-docc/.build + - name: Build swift-docc + if: ${{ !steps.cache-docc.outputs.cache-hit }} + run: | + cd swift-docc; swift build --product docc -c release; cd .. + + - name: Checkout swift-docc-render + uses: actions/checkout@v2 + with: + repository: apple/swift-docc-render + ref: main + path: swift-docc-render + - name: Build swift-docc-render + run: | + cd swift-docc-render; npm install && npm run build; cd .. + + - name: Checkout gh-pages Branch + uses: actions/checkout@v2 + with: + ref: gh-pages + path: docs-out + + - name: Build documentation + run: > + rm -rf docs-out/.git; + rm -rf docs-out/main; + + for tag in $(echo "main"; git tag); + do + echo "⏳ Generating documentation for "$tag" release."; + + if [ -d "docs-out/$tag" ] + then + echo "✅ Documentation for "$tag" already exists."; + else + git checkout "$tag"; + mkdir -p Sources/VaporRouting/Documentation.docc; + export DOCC_HTML_DIR="$(pwd)/swift-docc-render/dist"; + + rm -rf .build/symbol-graphs; + mkdir -p .build/symbol-graphs; + swift build \ + --target VaporRouting \ + -Xswiftc \ + -emit-symbol-graph \ + -Xswiftc \ + -emit-symbol-graph-dir \ + -Xswiftc \ + .build/symbol-graphs \ + && swift-docc/.build/release/docc convert Sources/VaporRouting/Documentation.docc \ + --fallback-display-name VaporRouting \ + --fallback-bundle-identifier co.pointfree.VaporRouting \ + --fallback-bundle-version 0.0.0 \ + --additional-symbol-graph-dir \ + .build/symbol-graphs \ + --transform-for-static-hosting \ + --hosting-base-path /swift-vapor-routing/"$tag" \ + --output-path docs-out/"$tag" \ + && echo "✅ Documentation generated for "$tag" release." \ + || echo "⚠️ Documentation skipped for "$tag"."; + fi; + done + + - name: Fix permissions + run: 'sudo chown --recursive $USER docs-out' + - name: Publish documentation to GitHub Pages + uses: JamesIves/github-pages-deploy-action@4.1.7 + with: + branch: gh-pages + folder: docs-out + single-commit: true diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000000..dc82144b9d --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,27 @@ +name: Format + +on: + push: + branches: + - main + +jobs: + swift_format: + name: swift-format + runs-on: macOS-11 + steps: + - uses: actions/checkout@v2 + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Tap + run: brew tap pointfreeco/formulae + - name: Install + run: brew install Formulae/swift-format@5.5 + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Run swift-format + branch: 'main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..3b29812086 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..96581fcaf9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Point-Free + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..6dc2003eaf --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +PLATFORM_MACOS = macOS + +default: test + +test: + xcodebuild test \ + -scheme vapor-routing \ + -destination platform="$(PLATFORM_MACOS)" + +test-linux: + docker run \ + --rm \ + -v "$(PWD):$(PWD)" \ + -w "$(PWD)" \ + swift:5.3 \ + bash -c 'make test-swift' + +test-swift: + swift test \ + --enable-test-discovery \ + --parallel + +format: + swift format --in-place --recursive \ + ./Package.swift ./Sources ./Tests + find . -type f -name '*.md' -print0 | xargs -0 perl -pi -e 's/ +$$//' + +.PHONY: format test \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000000..3a51923459 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,187 @@ +{ + "object": { + "pins": [ + { + "package": "async-http-client", + "repositoryURL": "https://github.com/swift-server/async-http-client.git", + "state": { + "branch": null, + "revision": "7a4dfe026f6ee0f8ad741b58df74c60af296365d", + "version": "1.9.0" + } + }, + { + "package": "async-kit", + "repositoryURL": "https://github.com/vapor/async-kit.git", + "state": { + "branch": null, + "revision": "e2f741640364c1d271405da637029ea6a33f754e", + "version": "1.11.1" + } + }, + { + "package": "console-kit", + "repositoryURL": "https://github.com/vapor/console-kit.git", + "state": { + "branch": null, + "revision": "75ea3b627d88221440b878e5dfccc73fd06842ed", + "version": "4.2.7" + } + }, + { + "package": "multipart-kit", + "repositoryURL": "https://github.com/vapor/multipart-kit.git", + "state": { + "branch": null, + "revision": "2dd9368a3c9580792b77c7ef364f3735909d9996", + "version": "4.5.1" + } + }, + { + "package": "routing-kit", + "repositoryURL": "https://github.com/vapor/routing-kit.git", + "state": { + "branch": null, + "revision": "5603b81ceb744b8318feab1e60943704977a866b", + "version": "4.3.1" + } + }, + { + "package": "swift-backtrace", + "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", + "state": { + "branch": null, + "revision": "d3e04a9d4b3833363fb6192065b763310b156d54", + "version": "1.3.1" + } + }, + { + "package": "swift-case-paths", + "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", + "state": { + "branch": null, + "revision": "ce9c0d897db8a840c39de64caaa9b60119cf4be8", + "version": "0.8.1" + } + }, + { + "package": "swift-crypto", + "repositoryURL": "https://github.com/apple/swift-crypto.git", + "state": { + "branch": null, + "revision": "067254c79435de759aeef4a6a03e43d087d61312", + "version": "2.0.5" + } + }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", + "version": "1.4.2" + } + }, + { + "package": "swift-metrics", + "repositoryURL": "https://github.com/apple/swift-metrics.git", + "state": { + "branch": null, + "revision": "eadb828f878fed144387e3845866225bb7082c56", + "version": "2.3.0" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "d6e3762e0a5f7ede652559f53623baf11006e17c", + "version": "2.39.0" + } + }, + { + "package": "swift-nio-extras", + "repositoryURL": "https://github.com/apple/swift-nio-extras.git", + "state": { + "branch": null, + "revision": "f73ca5ee9c6806800243f1ac415fcf82de9a4c91", + "version": "1.10.2" + } + }, + { + "package": "swift-nio-http2", + "repositoryURL": "https://github.com/apple/swift-nio-http2.git", + "state": { + "branch": null, + "revision": "50c25c132b140e62b45e90b5a76f13ded02c8a46", + "version": "1.20.1" + } + }, + { + "package": "swift-nio-ssl", + "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "branch": null, + "revision": "b5260a31c2a72a89fa684f5efb3054d8725a2316", + "version": "2.18.0" + } + }, + { + "package": "swift-nio-transport-services", + "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", + "state": { + "branch": null, + "revision": "8ab824b140d0ebcd87e9149266ddc353e3705a3e", + "version": "1.11.4" + } + }, + { + "package": "swift-parsing", + "repositoryURL": "https://github.com/pointfreeco/swift-parsing", + "state": { + "branch": null, + "revision": "28d32e9ace1c4c43f5e5a177be837a202494c2d5", + "version": "0.9.2" + } + }, + { + "package": "URLRouting", + "repositoryURL": "https://github.com/pointfreeco/swift-url-routing", + "state": { + "branch": null, + "revision": "53f56e552b3932d5c11252cb6669a059e9e9e69a", + "version": "0.1.0" + } + }, + { + "package": "vapor", + "repositoryURL": "https://github.com/vapor/vapor.git", + "state": { + "branch": null, + "revision": "a3e72deb1a293ad80d2005e344c226b0f4b084d6", + "version": "4.56.0" + } + }, + { + "package": "websocket-kit", + "repositoryURL": "https://github.com/vapor/websocket-kit.git", + "state": { + "branch": null, + "revision": "e32033ad3c68ebec1b761bc961be7bd56bad02f8", + "version": "2.3.1" + } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "50a70a9d3583fe228ce672e8923010c8df2deddd", + "version": "0.2.1" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000000..e2837e43f0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:5.5 + +import PackageDescription + +let package = Package( + name: "vapor-routing", + platforms: [ + .macOS(.v12) + ], + products: [ + .library( + name: "VaporRouting", + targets: ["VaporRouting"] + ) + ], + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), + .package(url: "https://github.com/pointfreeco/swift-url-routing", from: "0.1.0"), + ], + targets: [ + .target( + name: "VaporRouting", + dependencies: [ + .product(name: "Vapor", package: "vapor"), + .product(name: "URLRouting", package: "swift-url-routing"), + ] + ), + .testTarget( + name: "VaporRoutingTests", + dependencies: [ + "VaporRouting", + .product(name: "XCTVapor", package: "vapor"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000000..e2442f7fd6 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# vapor-routing + +A routing library for [Vapor][vapor] with a focus on type safety, composition and URL generation. + +--- + +* [Motivation](#Motivation) +* [Getting started](#Getting-started) +* [Documentation](#Documentation) +* [License](#License) + +## Learn More + +This library was discussed in an [episode](http://pointfree.co/episodes/ep188-tour-of-parser-printers-vapor-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). + + + + + +## Motivation + +Routing in [Vapor][vapor] has a simple API that is similar to popular web frameworks in other languages, such as Ruby's [Sinatra][sinatra] or Node's [Express][express]. It works well for simple routes, but complexity grows over time due to lack of type safety and inability to _generate_ correct URLs to pages on your site. + +To see this, consider an endpoint to fetch a book that is associated with a particular user: + +```swift +// GET /users/:userId/books/:bookId +app.get("users", ":userId", "books", ":bookId") { req -> Response in + guard + let userId = req.parameters.get("userId", Int.self), + let bookId = req.parameters.get("bookId", Int.self) + else { + struct BadRequest: Error {} + throw BadRequest() + } + + // Logic for fetching user and book and constructing response... + let user = try await database.fetchUser(user.id) + let book = try await database.fetchBook(book.id) + return BookResponse(...) +} +``` + +When a URL request is made to the server whose method and path matches the above pattern, the closure will be executed for handling that endpoint's logic. + +Notice that we must sprinkle in validation code and error handling into the endpoint's logic in order to coerce the stringy parameter types into first class data types. This obscures the real logic of the endpoint, and any changes to the route's pattern must be kept in sync with the validation logic, such as if we wanted to rename "users" to "user" and "books" to "book". + +In addition to these drawbacks, we often need to be able to generate a valid URL to the user's book page by specifying a user and book id. For example, suppose we wanted to generate an HTML page with a list of all the books for a user, including a link to each book. We have no choice but to manually interpolate a string to form the URL: + +```swift +Node.ul( + user.books.map { book in + .li( + .a(.href("/users/\(user.id)/book/\(book.id)"), book.title) + ) + } +) +``` +```html +
+``` + +It is our responsibility to make sure that this interpolated string matches exactly what was specified in the Vapor route. This can be tedious and error prone. + +In fact, there is a typo in the above code. The URL constructed goes to "/book/:bookId", but really it should be "/book*s*/:bookId": + +```diff +- .a(.href("/users/\(user.id)/book/\(book.id)"), book.title) ++ .a(.href("/users/\(user.id)/books/\(book.id)"), book.title) +``` + +This library aims to solve these problems, and more, when dealing with routing in a Vapor application. + +## Getting started + +To use this libary, one starts by constructing an enum that describes all the routes your website supports. For example, the book endpoint described above can be represented as: + +```swift +enum SiteRoute { + case userBook(userId: Int, bookId: Int) + // more cases for each route +} +``` + +Then you construct a router as a parser-printer from our [parsing library][swift-parsing], which is an object that is capable of parsing URL requests in `SiteRoute` and _printing_ `SiteRoute` values back into URL requests. Such routers can be constructed with various parser-printers the library vends, such as `Path`, `Query`, `Body` and more: + +```swift +import VaporRouting + +let siteRouter = OneOf { + // Maps the URL "/users/:userId/books/:bookId" to the + // SiteRouter.userBook enum case. + Route(.case(SiteRouter.userBook)) { + Path { "users"; Digits(); "books"; Digits() } + } + + // More uses of Route for each case in SiteRoute +} +``` + +Once that little bit of upfront work is done, using the router doesn't look too dissimilar from using Vapor's native routing tools. First you mount the router to the application to take care of all routing responsibilities, and you do so by providing a closure that transforms `SiteRoute` to a response: + +```swift +// configure.swift +public func configure(_ app: Application) throws { + ... + + app.mount(siteRouter, use: siteHandler) +} + +func siteHandler( + request: Request, + route: SiteRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .userBook(userId: userId, bookId: bookId): + let user = try await database.fetchUser(user.id) + let book = try await database.fetchBook(book.id) + return BookResponse(...) + + // more cases... + } +} +``` + +Notice that handling the `.userBook` case is entirely focused on just the logic for the endpoint, not parsing and validating the parameters in the URL. + +With that done you can now easily generate URLs to any part of your website usinge a type safe, concise API. For example, generating the list of book links now looks like this: + +```swift +Node.ul( + user.books.map { book in + .li( + .a( + .href(siteRouter.path(for: .userBook(userId: user.id, bookId: book.id)), + book.title + ) + ) + } +) +``` + +Note there is no string interpolation or guessing what shape the path should be in. All of that is handled by the router. We only have to provide the data for the user and book ids, and the router takes care of the rest. If we make a change to the `siteRouter`, such as recognizer the singular form "/user/:userId/book/:bookId", then all paths will automatically be updated. We will not need to search the code base to replace "users" with "user" and "books" with "book". + +## Documentation + +The documentation for releases and main are available here: + +* [main][vapor-routing-docs] +* [0.1.0](https://pointfreeco.github.io/vapor-routing/0.1.0/documentation/vaporrouting) + +## License + +This library is released under the MIT license. See [LICENSE](LICENSE) for details. + +[vapor-routing-docs]: https://pointfreeco.github.io/vapor-routing +[vapor]: http://vapor.codes +[swift-parsing]: http://github.com/pointfreeco/swift-parsing +[express]: http://expressjs.com +[sinatra]: http://sinatrarb.com diff --git a/Sources/VaporRouting/Documentation.docc/VaporRouting.md b/Sources/VaporRouting/Documentation.docc/VaporRouting.md new file mode 100644 index 0000000000..06464e2f61 --- /dev/null +++ b/Sources/VaporRouting/Documentation.docc/VaporRouting.md @@ -0,0 +1,227 @@ +# ``VaporRouting`` + +A bidirectional Vapor router with more type safety and less fuss. + +## Additional Resources + +- [GitHub Repo](https://github.com/pointfreeco/vapor-routing) +- [Discussions](https://github.com/pointfreeco/vapor-routing/discussions) +- [Point-Free Video][vapor-routing-video] + +## Motivation + +Routing in [Vapor][vapor] has a simple API that is similar to popular web frameworks in other languages, such as Ruby's [Sinatra][sinatra] or Node's [Express][express]. It works well for simple routes, but complexity grows over time due to lack of type safety and inability to _generate_ correct URLs to pages on your site. + +To see this, consider an endpoint to fetch a book that is associated with a particular user: + +```swift +// GET /users/:userId/books/:bookId +app.get("users", ":userId", "books", ":bookId") { req -> Response in + guard + let userId = req.parameters.get("userId", Int.self), + let bookId = req.parameters.get("bookId", Int.self) + else { + struct BadRequest: Error {} + throw BadRequest() + } + + // Logic for fetching user and book and constructing response... + let user = try await database.fetchUser(user.id) + let book = try await database.fetchBook(book.id) + return BookResponse(...) +} +``` + +When a URL request is made to the server whose method and path matches the above pattern, the closure will be executed for handling that endpoint's logic. + +Notice that we must sprinkle in validation code and error handling into the endpoint's logic in order to coerce the stringy parameter types into first class data types. This obscures the real logic of the endpoint, and any changes to the route's pattern must be kept in sync with the validation logic, such as if we wanted to rename "users" to "user" and "books" to "book". + +In addition to these drawbacks, we often need to be able to generate a valid URL to the user's book page by specifying a user and book id. For example, suppose we wanted to generate an HTML page with a list of all the books for a user, including a link to each book. We have no choice but to manually interpolate a string to form the URL: + +```swift +Node.ul( + user.books.map { book in + .li( + .a(.href("/users/\(user.id)/book/\(book.id)"), book.title) + ) + } +) +``` +```html + +``` + +It is our responsibility to make sure that this interpolated string matches exactly what was specified in the Vapor route. This can be tedious and error prone. + +In fact, there is a typo in the above code. The URL constructed goes to "/book/:bookId", but really it should be "/book*s*/:bookId": + +```diff +- .a(.href("/users/\(user.id)/book/\(book.id)"), book.title) ++ .a(.href("/users/\(user.id)/books/\(book.id)"), book.title) +``` + +This library aims to solve these problems, and more, when dealing with routing in a Vapor application. + +## Adding Parsing as a dependency + +To use the VaporRouting library in a SwiftPM project, add it to the dependencies of your Package.swift +and specify the `VaporRouting` product in any targets that need access to the library: + +```swift +let package = Package( + dependencies: [ + .package(url: "https://github.com/pointfreeco/vapor-routing", from: "0.1.0"), + ], + targets: [ + .target( + name: "