├── .github └── workflows │ ├── documentation.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Submissions │ ├── CreateRequest.swift │ ├── RequestMakeable.swift │ ├── UpdateRequest.swift │ └── ValidatableRequest.swift └── Tests ├── .gitkeep ├── LinuxMain.swift └── SubmissionsTests ├── Controllers └── PostController.swift ├── Helpers └── TestValidatorResultFailure.swift ├── Models ├── Entities │ └── Post.swift └── Requests │ ├── CreatePostRequest.swift │ └── UpdatePostRequest.swift └── SubmissionsTests.swift /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/documentation.yml 2 | name: Documentation 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Generate Documentation 15 | uses: SwiftDocOrg/swift-doc@master 16 | with: 17 | inputs: "Sources" 18 | module-name: Submissions 19 | output: "Documentation" 20 | - name: Upload Documentation to Wiki 21 | uses: SwiftDocOrg/github-wiki-publish-action@v1 22 | with: 23 | path: "Documentation" 24 | env: 25 | GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.WIKI_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | linux: 9 | runs-on: ubuntu-latest 10 | container: swift:5.2-bionic 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v2 14 | - name: Run tests with Thread Sanitizer 15 | run: swift test --enable-test-discovery --sanitize=thread 16 | macOS: 17 | runs-on: macos-latest 18 | steps: 19 | - name: Select latest available Xcode 20 | uses: maxim-lobanov/setup-xcode@v1 21 | with: 22 | xcode-version: latest 23 | - name: Check out code 24 | uses: actions/checkout@v2 25 | - name: Run tests with Thread Sanitizer 26 | run: swift test --enable-test-discovery --sanitize=thread -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .idea 4 | .DS_Store 5 | *.xcodeproj 6 | DerivedData/ 7 | Package.resolved 8 | .swiftpm 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nodes 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "submissions", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .library(name: "Submissions", targets: ["Submissions"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/vapor/vapor.git", from: "4.22.0") 14 | ], 15 | targets: [ 16 | .target(name: "Submissions", dependencies: [.product(name: "Vapor", package: "vapor")]), 17 | .testTarget(name: "SubmissionsTests", dependencies: [ 18 | .target(name:"Submissions"), 19 | .product(name: "XCTVapor", package: "vapor") 20 | ]) 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Submissions 📩 2 | [![Swift Version](https://img.shields.io/badge/Swift-5.2-brightgreen.svg)](http://swift.org) 3 | [![Vapor Version](https://img.shields.io/badge/Vapor-4.0-30B6FC.svg)](http://vapor.codes) 4 | ![tests](https://github.com/nodes-vapor/submissions/workflows/test/badge.svg) 5 | [![codebeat](https://codebeat.co/badges/b9c894d6-8c6a-4a07-bfd5-29db898c8dfe)](https://codebeat.co/projects/github-com-nodes-vapor-submissions-master) 6 | [![Readme Score](http://readme-score-api.herokuapp.com/score.svg?url=https://github.com/nodes-vapor/submissions)](http://clayallsopp.github.io/readme-score?url=https://github.com/nodes-vapor/submissions) 7 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/reset/master/LICENSE) 8 | 9 | # Installation 10 | 11 | ## Package.swift 12 | 13 | Add `Submissions` to the Package dependencies: 14 | ```swift 15 | dependencies: [ 16 | ..., 17 | .package(url: "https://github.com/nodes-vapor/submissions.git", from: "3.0.0") 18 | ] 19 | ``` 20 | 21 | as well as to your target (e.g. "App"): 22 | 23 | ```swift 24 | targets: [ 25 | ... 26 | .target( 27 | name: "App", 28 | dependencies: [ 29 | ... 30 | .product(name: "Submissions", package: "submissions") 31 | ] 32 | ), 33 | ... 34 | ] 35 | ``` 36 | 37 | ## Introduction 38 | 39 | Submissions was written to reduce the amount of boilerplate needed to write the common tasks of rendering forms and processing and validating data from POST/PUT/PATCH requests (PPP-request, or _submission_ for short). Submissions makes it easy to present detailed validation errors for web users as well as API consumers. 40 | 41 | Submissions is designed to be flexible. Its functionality is based around `Field`s which are abstractions that model the parts of a _submission_. 42 | 43 | single values with its validators and meta data such as a label. Usually a form or API request involves multiple properties comprising a model. This can be modeled using multiple `Field`s. 44 | 45 | ## Getting started 🚀 46 | 47 | First make sure that you've imported Submissions everywhere it's needed: 48 | 49 | ```swift 50 | import Submissions 51 | ``` 52 | 53 | ### Adding the Provider 54 | 55 | "Submissions" comes with a light-weight provider that we'll need to register in the `configure` function in our `configure.swift` file: 56 | 57 | ```swift 58 | try services.register(SubmissionsProvider()) 59 | ``` 60 | 61 | This makes sure that fields and errors can be stored on the request using a `FieldCache` service. 62 | 63 | ## Validating API requests 64 | 65 | _TODO_ 66 | 67 | ## Validating HTML form requests 68 | 69 | Submissions comes with leaf tags that can render fields into HTML. The leaf files needs to be copied from the folder `Resources/Views/Submissions` from `Submissions` to your project's `Resources/Views`. Then we can register Submissions' leaf tags where you register your other leaf tags, for instance: 70 | 71 | ```swift 72 | var leafTagConfig = LeafTagConfig.default() 73 | ... 74 | leafTagConfig.useSubmissionsLeafTags() 75 | services.register(leafTagConfig) 76 | ``` 77 | 78 | You can customize where Submissions looks for the leaf tags by passing in a modified instance of `TagTemplatePaths` to `useSubmissionsLeafTags(paths:)`. 79 | 80 | In order to render a view that contains Submissions leaf tags we need to ensure that the `Field`s are added to the field cache and that the `Request` is passed into the `render` call: 81 | 82 | ```swift 83 | let nameField = Field(key: "name", value: "", label: "Name") 84 | try req.fieldCache().addFields([nameField]) 85 | try req.view().render("index", on: req) 86 | ``` 87 | 88 | In your leaf file you can then refer to this field using an appropriate tag and the key "name" as defined when creating the Field. 89 | 90 | ### Tags 91 | 92 | #### Input tags 93 | 94 | The following input tags are available for your leaf files. 95 | 96 | ``` 97 | #submissions:checkbox( ... ) 98 | #submissions:email( ... ) 99 | #submissions:hidden( ... ) 100 | #submissions:password( ... ) 101 | #submissions:text( ... ) 102 | #submissions:textarea( ... ) 103 | ``` 104 | 105 | They all accept the same number of parameters. 106 | 107 | With these options: 108 | 109 | Position | Type | Description | Example | Required? 110 | -|-|-|-|- 111 | 1 | key | Key to the related field in the field cache | _"name"_ | yes 112 | 2 | placeholder | Placeholder text | _"Enter name"_ | no 113 | 3 | help text | Help text | _"This name will be visible to others"_ | no 114 | 115 | #### File tag 116 | 117 | To add a file upload to your form use this leaf tag. 118 | 119 | ``` 120 | #submissions:file( ... ) 121 | ``` 122 | 123 | With these options: 124 | 125 | Position | Type | Description | Example | Required? 126 | -|-|-|-|- 127 | 1 | key | Key to the related field in the field cache | _"avatar"_ | yes 128 | 2 | help text | Help text | _"This will replace your existing avatar"_ | no 129 | 3 | accept | Placeholder text | _"image/*"_ | no 130 | 4 | multiple | Support multple file uploads | _"true"_ (or any other non-nil value) | no 131 | 132 | 133 | #### Select tag 134 | 135 | A select tag can be added as follows. 136 | 137 | ``` 138 | #submissions:select( ... ) 139 | ``` 140 | 141 | With these options: 142 | 143 | Position | Type | Description | Example | Required? 144 | -|-|-|-|- 145 | 1 | key | Key to the related field in the field cache | _"role"_ | yes 146 | 2 | options | The possible options in the drop down | _roles_ | no 147 | 3 | placeholder | Placeholder text | _"Select an role"_ | no 148 | 4 | help text | Help text | _"The role defines the actions a user is allowed to perform"_ | no 149 | 150 | The second option (e.g. `roles`) is a special parameter that defines the dropdown options. It has to be passed into the render call something like this. 151 | 152 | ```swift 153 | enum Role: String, CaseIterable, Codable { 154 | case user, admin, superAdmin 155 | } 156 | 157 | extension Role: OptionRepresentable { 158 | var optionID: String? { 159 | return self.rawValue 160 | } 161 | 162 | var optionValue: String? { 163 | return self.rawValue.uppercased() 164 | } 165 | } 166 | 167 | let roles: [Role] = . 168 | try req.view().render("index", ["roles": roles.allCases.makeOptions()] on: req) 169 | ``` 170 | 171 | ## 🏆 Credits 172 | 173 | This package is developed and maintained by the Vapor team at [Nodes](https://www.nodesagency.com). 174 | 175 | ## 📄 License 176 | 177 | This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). 178 | -------------------------------------------------------------------------------- /Sources/Submissions/CreateRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol CreateRequest: ValidatableRequest { 4 | associatedtype Model 5 | 6 | func create(on request: Request) -> EventLoopFuture 7 | } 8 | 9 | public extension CreateRequest { 10 | static func create(on request: Request) -> EventLoopFuture { 11 | validated(on: request).flatMap { $0.create(on: request) } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Submissions/RequestMakeable.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol RequestMakeable { 4 | static func make(from request: Request) -> EventLoopFuture 5 | } 6 | 7 | public extension RequestMakeable where Self: Decodable { 8 | static func make(from request: Request) -> EventLoopFuture { 9 | request.eventLoop.future(result: .init { try request.content.decode(Self.self) }) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Submissions/UpdateRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol UpdateRequest: RequestMakeable { 4 | associatedtype Model 5 | 6 | static func find(on request: Request) -> EventLoopFuture 7 | static func validations(for model: Model, on request: Request) -> EventLoopFuture 8 | 9 | func update(_ model: Model, on request: Request) -> EventLoopFuture 10 | } 11 | 12 | public extension UpdateRequest { 13 | static func validations( 14 | for _: Model, 15 | on request: Request 16 | ) -> EventLoopFuture { 17 | request.eventLoop.future(Validations()) 18 | } 19 | } 20 | 21 | public extension UpdateRequest { 22 | static func update(on request: Request) -> EventLoopFuture { 23 | find(on: request).flatMap { model in 24 | validations(for: model, on: request).flatMapThrowing { validations in 25 | try validations.validate(request: request).assert() 26 | }.flatMap { 27 | make(from: request) 28 | }.flatMap { 29 | $0.update(model, on: request) 30 | } 31 | } 32 | } 33 | } 34 | 35 | public extension UpdateRequest where Model: Authenticatable { 36 | static func find(on request: Request) -> EventLoopFuture { 37 | request.eventLoop.future(result: .init { try request.auth.require() }) 38 | } 39 | } 40 | 41 | public extension UpdateRequest where Self: Validatable { 42 | static func validations(for _: Model, on request: Request) -> EventLoopFuture { 43 | request.eventLoop.future(validations()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Submissions/ValidatableRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol ValidatableRequest: RequestMakeable { 4 | static func validations(on request: Request) -> EventLoopFuture 5 | } 6 | 7 | public extension ValidatableRequest { 8 | static func validations(on request: Request) -> EventLoopFuture { 9 | request.eventLoop.future(Validations()) 10 | } 11 | } 12 | 13 | public extension ValidatableRequest where Self: Validatable { 14 | static func validations(on request: Request) -> EventLoopFuture { 15 | request.eventLoop.future(validations()) 16 | } 17 | } 18 | 19 | public extension ValidatableRequest { 20 | static func validated(on request: Request) -> EventLoopFuture { 21 | validations(on: request).flatMapThrowing { validations in 22 | try validations.validate(request: request).assert() 23 | }.flatMap { 24 | make(from: request) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/submissions/c2eb407e92b1a0015bf8f4f79683726d00f1d5f9/Tests/.gitkeep -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | fatalError("Please use swift test --enable-test-discovery to run the tests instead") 2 | -------------------------------------------------------------------------------- /Tests/SubmissionsTests/Controllers/PostController.swift: -------------------------------------------------------------------------------- 1 | import Submissions 2 | import Vapor 3 | 4 | struct PostController { 5 | func create(request: Request) -> EventLoopFuture { 6 | CreatePostRequest.create(on: request) 7 | } 8 | 9 | func update(request: Request) -> EventLoopFuture { 10 | UpdatePostRequest.update(on: request) 11 | } 12 | } 13 | 14 | extension PostController: RouteCollection { 15 | func boot(routes: RoutesBuilder) throws { 16 | routes.post("posts", use: create) 17 | routes.put("posts", use: update) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/SubmissionsTests/Helpers/TestValidatorResultFailure.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension ValidatorResults { 4 | struct TestFailure: ValidatorResult { 5 | var isFailure: Bool { true } 6 | var successDescription: String? { nil } 7 | var failureDescription: String? { "has failed" } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SubmissionsTests/Models/Entities/Post.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class Post: Content { 4 | var title: String 5 | 6 | init(title: String) { 7 | self.title = title 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SubmissionsTests/Models/Requests/CreatePostRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Submissions 3 | 4 | struct CreatePostRequest: Content, CreateRequest { 5 | let title: String 6 | 7 | func create(on request: Request) -> EventLoopFuture { 8 | request.eventLoop.future(Post(title: title)) 9 | } 10 | 11 | static func validations(on request: Request) -> EventLoopFuture { 12 | var validations = Validations() 13 | if request.url.query == "fail" { 14 | validations.add("validation", result: ValidatorResults.TestFailure()) 15 | } 16 | return request.eventLoop.future(validations) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/SubmissionsTests/Models/Requests/UpdatePostRequest.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Submissions 3 | 4 | struct UpdatePostRequest: Content, UpdateRequest { 5 | static func find(on request: Request) -> EventLoopFuture { 6 | // Here we return a new, empty post 7 | // 8 | // Real world implementations could include: 9 | // - loading a model from the database 10 | // - getting the model from the authentication cache (updating logged-in user) 11 | request.eventLoop.future(Post(title: "")) 12 | } 13 | 14 | let title: String? 15 | 16 | static func validations( 17 | for _: Post, 18 | on request: Request 19 | ) -> EventLoopFuture { 20 | var validations = Validations() 21 | if request.url.query == "fail" { 22 | validations.add("validation", result: ValidatorResults.TestFailure()) 23 | } 24 | return request.eventLoop.future(validations) 25 | } 26 | 27 | func update(_ post: Post, on request: Request) -> EventLoopFuture { 28 | if let title = title { 29 | post.title = title 30 | } 31 | return request.eventLoop.future(post) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/SubmissionsTests/SubmissionsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTVapor 2 | 3 | final class SubmissionsTests: XCTestCase { 4 | var app: Application! 5 | 6 | override func setUp() { 7 | app = Application(.testing) 8 | try! app.register(collection: PostController()) 9 | } 10 | 11 | override func tearDown() { 12 | app.shutdown() 13 | app = nil 14 | } 15 | 16 | func test_create() throws { 17 | try app.test(.POST, "posts", beforeRequest: { request in 18 | try request.content.encode(["title": "Some title"], as: .json) 19 | }) { response in 20 | XCTAssertEqual(response.status, .ok) 21 | 22 | // test post response 23 | let post = try response.content.decode(Post.self) 24 | XCTAssertEqual(post.title, "Some title") 25 | } 26 | } 27 | 28 | func test_create_includesValidation() throws { 29 | try app.test(.POST, "posts?fail", beforeRequest: { request in 30 | try request.content.encode(["title": "Some title"], as: .json) 31 | }) { response in 32 | XCTAssertEqual(response.status, .badRequest) 33 | 34 | let errorReason: String = try response.content.get(at: "reason") 35 | XCTAssertEqual(errorReason, "validation has failed") 36 | } 37 | } 38 | 39 | func test_update() throws { 40 | try app.test(.PUT, "posts", beforeRequest: { request in 41 | try request.content.encode(["title": "Updated title"], as: .json) 42 | }) { response in 43 | XCTAssertEqual(response.status, .ok) 44 | 45 | // test post response 46 | let post = try response.content.decode(Post.self) 47 | XCTAssertEqual(post.title, "Updated title") 48 | } 49 | } 50 | 51 | func test_update_includesValidations() throws { 52 | try app.test(.PUT, "posts?fail", beforeRequest: { request in 53 | try request.content.encode(["title": "Updated title"], as: .json) 54 | }) { response in 55 | XCTAssertEqual(response.status, .badRequest) 56 | 57 | let errorReason: String = try response.content.get(at: "reason") 58 | XCTAssertEqual(errorReason, "validation has failed") 59 | } 60 | } 61 | } 62 | --------------------------------------------------------------------------------